- 逆向分析 QZMusic_PC pluginSystem.ts 和 CeruMusic 插件文档 - 重写 detector.js: 支持 webpack 打包和混淆代码解析 - 正确提取 pluginInfo.info.id 识别音乐平台(mg/wy/tx/kw/kg) - 提取 QZ API 端点(api.qz.shiqianjiang.cn)和 X-API-KEY - 重写 tester.js: 自动测试 QZ API 端点+逐音质级别测试 - 前端更新: 音质测试状态显示(PASS/FAIL)、QZ API 信息展示 - MD报告增加 QZ API 状态列和音质通过率 - 完整音质等级体系: 128k~master 共14级
680 lines
32 KiB
JavaScript
680 lines
32 KiB
JavaScript
/**
|
||
* Koneko 音源测试平台 - 前端逻辑 v2.0
|
||
* SSE 实时日志 + 上传进度 + 平台展示
|
||
* 支持 .qz/.zip/.js 文件上传和服务端解压
|
||
*/
|
||
|
||
const state = {
|
||
currentTab: 'url', files: [], results: [], testing: false, theme: 'light',
|
||
platforms: []
|
||
};
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initTheme(); initTabs(); initUrlInput(); initFileUpload();
|
||
initActions(); initModals(); loadPlatforms(); lucide.createIcons();
|
||
});
|
||
|
||
// === 主题 ===
|
||
function initTheme() {
|
||
const saved = localStorage.getItem('koneko-theme') || 'light';
|
||
setTheme(saved);
|
||
$('themeBtn').addEventListener('click', (e) => { e.stopPropagation(); $('themeDropdown').classList.toggle('show'); });
|
||
document.addEventListener('click', () => $('themeDropdown').classList.remove('show'));
|
||
document.querySelectorAll('.theme-option').forEach(opt => {
|
||
opt.addEventListener('click', (e) => { e.stopPropagation(); setTheme(opt.dataset.theme); $('themeDropdown').classList.remove('show'); });
|
||
});
|
||
}
|
||
function setTheme(theme) {
|
||
state.theme = theme;
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
localStorage.setItem('koneko-theme', theme);
|
||
const labels = { light:'浅色', dark:'深色', sakura:'樱花', ocean:'海洋', forest:'森林', midnight:'暗夜紫', sunset:'落日' };
|
||
$('themeLabel').textContent = labels[theme] || '浅色';
|
||
document.querySelectorAll('.theme-option').forEach(opt => opt.classList.toggle('active', opt.dataset.theme === theme));
|
||
lucide.createIcons();
|
||
}
|
||
|
||
// === Tabs ===
|
||
function initTabs() {
|
||
document.querySelectorAll('.input-tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.input-tab').forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
state.currentTab = tab.dataset.tab;
|
||
$('urlTab').style.display = state.currentTab === 'url' ? 'block' : 'none';
|
||
$('fileTab').style.display = state.currentTab === 'file' ? 'block' : 'none';
|
||
});
|
||
});
|
||
}
|
||
|
||
// === URL 输入 ===
|
||
function initUrlInput() {
|
||
$('urlInput').addEventListener('input', () => {
|
||
const lines = $('urlInput').value.split('\n').map(l => l.trim()).filter(l => l);
|
||
$('urlCount').textContent = `${lines.length} 个链接`;
|
||
});
|
||
}
|
||
|
||
// === 文件上传(带进度) ===
|
||
function initFileUpload() {
|
||
const dropZone = $('fileDropZone'), fileInput = $('fileInput');
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); });
|
||
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
|
||
}
|
||
|
||
function handleFiles(fileList) {
|
||
for (const file of fileList) {
|
||
if (file.name.match(/\.(js|zip|qz)$/i)) state.files.push(file);
|
||
}
|
||
renderFileList();
|
||
}
|
||
|
||
function renderFileList() {
|
||
const container = $('fileList');
|
||
container.innerHTML = '';
|
||
state.files.forEach((file, idx) => {
|
||
const chip = document.createElement('div');
|
||
chip.className = 'file-chip';
|
||
chip.innerHTML = `<i data-lucide="${file.name.match(/\.(zip|qz)$/i) ? 'file-archive' : 'file-code'}"></i><span>${file.name}</span><span class="remove-file" data-idx="${idx}"><i data-lucide="x"></i></span>`;
|
||
chip.querySelector('.remove-file').addEventListener('click', () => { state.files.splice(idx, 1); renderFileList(); });
|
||
container.appendChild(chip);
|
||
});
|
||
lucide.createIcons();
|
||
}
|
||
|
||
// 逐个读取文件,显示进度
|
||
// .qz/.zip 文件上传到服务端解压,.js 文件直接读取
|
||
async function readFilesWithProgress(files) {
|
||
const plugins = [];
|
||
const total = files.length;
|
||
$('uploadProgress').style.display = 'block';
|
||
|
||
// 先将所有 .qz/.zip 文件批量上传到服务端解压
|
||
const archiveFiles = files.filter(f => f.name.match(/\.(qz|zip)$/i));
|
||
const jsFiles = files.filter(f => !f.name.match(/\.(qz|zip)$/i));
|
||
|
||
if (archiveFiles.length > 0) {
|
||
$('uploadProgressText').textContent = `正在上传 ${archiveFiles.length} 个压缩包到服务端解压...`;
|
||
$('uploadProgressPercent').textContent = '0%';
|
||
$('uploadProgressFill').style.width = '0%';
|
||
|
||
const formData = new FormData();
|
||
for (const f of archiveFiles) formData.append('files', f);
|
||
|
||
try {
|
||
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
||
const data = await res.json();
|
||
if (data.success && data.plugins) {
|
||
for (const p of data.plugins) {
|
||
plugins.push({ code: p.code, filename: p.filename });
|
||
}
|
||
$('uploadProgressText').textContent = `解压完成: ${archiveFiles.length} 个文件 -> ${data.plugins.length} 个插件`;
|
||
$('uploadProgressPercent').textContent = '100%';
|
||
$('uploadProgressFill').style.width = '100%';
|
||
addLog('success', `压缩包解压完成: ${archiveFiles.length} 个文件 -> ${data.plugins.length} 个插件`);
|
||
} else {
|
||
addLog('error', `压缩包解压失败: ${data.error || '未知错误'}`);
|
||
}
|
||
} catch (err) {
|
||
addLog('error', `上传失败: ${err.message}`);
|
||
}
|
||
await sleep(300);
|
||
}
|
||
|
||
// 读取 JS 文件
|
||
for (let i = 0; i < jsFiles.length; i++) {
|
||
const file = jsFiles[i];
|
||
const pct = Math.round(((archiveFiles.length > 0 ? 1 : 0 + i) / total) * 100);
|
||
$('uploadProgressText').textContent = `读取文件 ${i + 1}/${jsFiles.length}: ${file.name}`;
|
||
$('uploadProgressPercent').textContent = `${Math.min(100, pct)}%`;
|
||
$('uploadProgressFill').style.width = `${Math.min(100, pct)}%`;
|
||
const code = await readFile(file);
|
||
plugins.push({ code, filename: file.name });
|
||
await sleep(50);
|
||
}
|
||
|
||
$('uploadProgressText').textContent = `全部文件读取完成`;
|
||
$('uploadProgressPercent').textContent = `100%`;
|
||
$('uploadProgressFill').style.width = `100%`;
|
||
await sleep(300);
|
||
$('uploadProgress').style.display = 'none';
|
||
return plugins;
|
||
}
|
||
|
||
function readFile(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = reject;
|
||
reader.readAsText(file);
|
||
});
|
||
}
|
||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||
|
||
// === 平台展示 ===
|
||
async function loadPlatforms() {
|
||
try {
|
||
const res = await fetch('/api/platforms');
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
state.platforms = data.platforms;
|
||
renderPlatforms(data.platforms);
|
||
}
|
||
} catch(e) { console.error('加载平台信息失败', e); }
|
||
}
|
||
|
||
function renderPlatforms(platforms) {
|
||
const icons = { lx: 'music', qzv2: 'disc-3', musicfree: 'radio' };
|
||
const grid = $('platformsGrid');
|
||
grid.innerHTML = platforms.map(p => `
|
||
<div class="platform-info-card">
|
||
<div class="platform-info-header">
|
||
<div class="platform-info-icon ${p.id}"><i data-lucide="${icons[p.id] || 'circle'}"></i></div>
|
||
<div>
|
||
<div class="platform-info-name">${p.name} <span style="color:var(--text-muted);font-size:12px;font-weight:400;">${p.fullName}</span></div>
|
||
<div class="platform-info-format">${p.format}</div>
|
||
</div>
|
||
</div>
|
||
<div class="platform-info-section">
|
||
<div class="platform-info-label">支持音质</div>
|
||
<div class="platform-info-tags">${p.qualities.map(q => `<span class="platform-info-tag">${q}</span>`).join('')}</div>
|
||
</div>
|
||
<div class="platform-info-section">
|
||
<div class="platform-info-label">功能</div>
|
||
<div class="platform-info-tags">${p.features.slice(0,6).map(f => `<span class="platform-info-tag">${f}</span>`).join('')}${p.features.length > 6 ? `<span class="platform-info-tag">+${p.features.length - 6}</span>` : ''}</div>
|
||
</div>
|
||
<div class="platform-info-section">
|
||
<div class="platform-info-label">检测特征</div>
|
||
<div class="platform-info-tags">${p.detectionHints.map(h => `<span class="platform-info-tag">${h}</span>`).join('')}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
$('platformsCard').style.display = 'block';
|
||
lucide.createIcons();
|
||
}
|
||
|
||
// === 按钮 ===
|
||
function initActions() {
|
||
$('clearBtn').addEventListener('click', clearAll);
|
||
$('sampleBtn').addEventListener('click', fillSample);
|
||
$('testBtn').addEventListener('click', startTest);
|
||
$('exportMdBtn').addEventListener('click', exportMarkdown);
|
||
$('exportPosterBtn').addEventListener('click', exportPoster);
|
||
$('clearLogBtn').addEventListener('click', () => { $('logPanel').innerHTML = ''; });
|
||
}
|
||
|
||
function clearAll() {
|
||
$('urlInput').value = ''; $('urlCount').textContent = '0 个链接';
|
||
state.files = []; renderFileList();
|
||
toast('已清空输入', 'info');
|
||
}
|
||
|
||
function fillSample() {
|
||
$('urlInput').value = 'https://paste.rs/WqzPv';
|
||
$('urlInput').dispatchEvent(new Event('input'));
|
||
toast('已填入示例链接', 'info');
|
||
}
|
||
|
||
// === 日志面板 ===
|
||
function showLogPanel() {
|
||
$('logPanelCard').style.display = 'block';
|
||
$('logPulse').classList.remove('idle');
|
||
}
|
||
|
||
function addLog(level, message, isHeader = false) {
|
||
const panel = $('logPanel');
|
||
const now = new Date();
|
||
const time = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
|
||
const entry = document.createElement('div');
|
||
entry.className = `log-entry${isHeader ? ' plugin-header' : ''}`;
|
||
entry.innerHTML = `<span class="log-time">${time}</span><span class="log-level ${level}">${level.toUpperCase()}</span><span class="log-message">${escapeHtml(message)}</span>`;
|
||
panel.appendChild(entry);
|
||
panel.scrollTop = panel.scrollHeight;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// === 开始测试(SSE 流式) ===
|
||
async function startTest() {
|
||
if (state.testing) return;
|
||
|
||
const plugins = [];
|
||
// 收集 URL
|
||
if (state.currentTab === 'url') {
|
||
const urls = $('urlInput').value.split('\n').map(l => l.trim()).filter(l => l && l.startsWith('http'));
|
||
if (urls.length === 0) { toast('请输入至少一个有效的插件链接', 'error'); return; }
|
||
urls.forEach(url => plugins.push({ url }));
|
||
}
|
||
// 收集文件(带进度)
|
||
if (state.currentTab === 'file') {
|
||
if (state.files.length === 0) { toast('请选择至少一个插件文件', 'error'); return; }
|
||
const filePlugins = await readFilesWithProgress(state.files);
|
||
plugins.push(...filePlugins);
|
||
}
|
||
|
||
state.testing = true;
|
||
state.results = [];
|
||
$('testBtn').disabled = true;
|
||
$('testBtn').innerHTML = '<div class="spinner"></div> 测试中...';
|
||
$('progressBar').classList.add('show');
|
||
$('progressFill').style.width = '0%';
|
||
$('emptyState').style.display = 'none';
|
||
$('resultsSection').style.display = 'block';
|
||
$('resultsGrid').innerHTML = '';
|
||
showLogPanel();
|
||
|
||
addLog('info', `开始批量测试,共 ${plugins.length} 个插件`);
|
||
|
||
try {
|
||
// 使用 fetch + ReadableStream 接收 SSE
|
||
const response = await fetch('/api/test/stream', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ plugins })
|
||
});
|
||
|
||
if (!response.ok) throw new Error('测试请求失败');
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
const resultsMap = new Map();
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
let currentEvent = '';
|
||
for (const line of lines) {
|
||
if (line.startsWith('event: ')) {
|
||
currentEvent = line.substring(7).trim();
|
||
} else if (line.startsWith('data: ') && currentEvent) {
|
||
try {
|
||
const data = JSON.parse(line.substring(6));
|
||
handleSSEEvent(currentEvent, data, resultsMap);
|
||
} catch(e) { /* skip */ }
|
||
currentEvent = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 渲染最终结果(按 index 排序)
|
||
state.results = [...resultsMap.entries()].sort((a,b) => a[0] - b[0]).map(([,v]) => v);
|
||
renderResults(state.results);
|
||
|
||
$('progressFill').style.width = '100%';
|
||
setTimeout(() => $('progressBar').classList.remove('show'), 500);
|
||
$('logPulse').classList.add('idle');
|
||
addLog('success', `全部测试完成,共 ${state.results.length} 个插件`);
|
||
toast(`测试完成,共 ${state.results.length} 个插件`, 'success');
|
||
|
||
} catch (err) {
|
||
toast(err.message, 'error');
|
||
addLog('error', `测试失败: ${err.message}`);
|
||
$('logPulse').classList.add('idle');
|
||
} finally {
|
||
state.testing = false;
|
||
$('testBtn').disabled = false;
|
||
$('testBtn').innerHTML = '<i data-lucide="play"></i> 开始测试';
|
||
lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
function handleSSEEvent(event, data, resultsMap) {
|
||
switch(event) {
|
||
case 'start':
|
||
addLog('info', `测试任务启动,时间: ${new Date(data.time).toLocaleTimeString('zh-CN')}`);
|
||
break;
|
||
case 'plugin_start':
|
||
addLog('info', `--- 插件 #${data.index + 1}: ${data.filename || data.url || '未知'} ---`, true);
|
||
break;
|
||
case 'log':
|
||
addLog(data.level, data.message);
|
||
break;
|
||
case 'plugin_done':
|
||
resultsMap.set(data.index, data.result);
|
||
// 增量渲染
|
||
renderIncrementalCard(data.index, data.result);
|
||
break;
|
||
case 'progress':
|
||
$('progressFill').style.width = data.percent + '%';
|
||
if (data.completed === data.total) {
|
||
addLog('info', `进度: ${data.completed}/${data.total} (100%)`);
|
||
}
|
||
break;
|
||
case 'done':
|
||
addLog('success', `测试任务完成`);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 增量渲染单个结果卡片
|
||
function renderIncrementalCard(index, result) {
|
||
const grid = $('resultsGrid');
|
||
let card = document.getElementById(`result-card-${index}`);
|
||
if (!card) {
|
||
card = document.createElement('div');
|
||
card.id = `result-card-${index}`;
|
||
grid.appendChild(card);
|
||
}
|
||
card.outerHTML = renderResultCard(result, index);
|
||
}
|
||
|
||
// === 渲染结果 ===
|
||
function renderResults(results) {
|
||
const grid = $('resultsGrid');
|
||
if (results.length === 0) {
|
||
grid.innerHTML = '<div class="empty-state"><i data-lucide="alert-circle"></i><p>没有测试结果</p></div>';
|
||
lucide.createIcons(); return;
|
||
}
|
||
grid.innerHTML = results.map((r, i) => renderResultCard(r, i)).join('');
|
||
lucide.createIcons();
|
||
}
|
||
|
||
function renderResultCard(r, index) {
|
||
const name = r.metadata?.name || r.filename || truncUrl(r.url) || `插件 #${index + 1}`;
|
||
const platform = r.platform || 'unknown';
|
||
const score = r.summary?.score ?? 0;
|
||
const grade = r.summary?.details?.grade ?? 'D';
|
||
const status = r.overallStatus || 'pending';
|
||
const musicPlatform = r.metadata?.musicPlatformName || '';
|
||
const musicPlatformId = r.metadata?.musicPlatform || '';
|
||
const isObf = r.metadata?.isObfuscated;
|
||
const platformIcons = { lx:'music', qzv2:'disc-3', musicfree:'radio', unknown:'help-circle' };
|
||
const platformTag = `<span class="platform-tag tag-${platform}"><i data-lucide="${platformIcons[platform] || 'help-circle'}"></i>${r.platformLabel || '未知'}</span>`;
|
||
const musicTag = musicPlatform ? `<span class="platform-tag tag-qzv2" style="font-size:11px;"><i data-lucide="radio"></i>${musicPlatform}</span>` : '';
|
||
const obfTag = isObf ? `<span class="platform-tag" style="font-size:11px;background:var(--warning);color:#fff;"><i data-lucide="eye-off"></i>混淆</span>` : '';
|
||
const scoreBadge = `<div class="score-badge grade-${grade}"><div class="score-num">${score}</div><div class="score-grade">${grade}</div></div>`;
|
||
|
||
// 延时测试 - 区分 QZ API 和直连 API
|
||
let latencyHtml = '';
|
||
if (r.latencyTests?.length > 0) {
|
||
const qzApiTests = r.latencyTests.filter(t => t.isQzApi);
|
||
const otherTests = r.latencyTests.filter(t => !t.isQzApi);
|
||
|
||
if (qzApiTests.length > 0) {
|
||
// QZ API 连通性测试
|
||
const basicTest = qzApiTests.find(t => t.quality === 'basic') || qzApiTests[0];
|
||
const qzStatus = basicTest.success ? 'success' : 'danger';
|
||
const qzIcon = basicTest.success ? 'check-circle' : 'x-circle';
|
||
latencyHtml += `<div style="margin-bottom:6px;"><span style="font-size:11px;color:var(--text-muted);"><i data-lucide="zap" style="width:12px;height:12px;display:inline;"></i> QZ API</span> <span style="color:var(--${qzStatus});font-size:11px;font-weight:600;">${basicTest.success ? basicTest.latency + 'ms' : '不通'}</span></div>`;
|
||
}
|
||
|
||
const displayTests = otherTests.slice(0, 3);
|
||
latencyHtml += displayTests.map(t => {
|
||
let cls = 'good'; if (t.latency > 1500) cls = 'bad'; else if (t.latency > 800) cls = 'ok';
|
||
const pct = Math.min(100, (t.latency / 3000) * 100);
|
||
const label = t.isPluginUrl ? '插件源' : truncUrl(t.url, 25);
|
||
return `<div class="latency-bar"><span class="latency-bar-label" title="${t.url}">${label}</span><div class="latency-bar-track"><div class="latency-bar-fill ${cls}" style="width:${pct}%"></div></div><span class="latency-bar-value ${cls}">${t.success ? t.latency + 'ms' : '失败'}</span></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// 音质测试 - 带状态指示
|
||
let qualityHtml = '';
|
||
if (r.qualityTests?.length > 0) {
|
||
qualityHtml = r.qualityTests.map(q => {
|
||
const label = q.label ? ` title="${q.label}"` : '';
|
||
let statusCls = 'untested';
|
||
let statusIcon = '';
|
||
if (q.tested) {
|
||
if (q.testStatus === 'pass') {
|
||
statusCls = 'qpass';
|
||
statusIcon = '<i data-lucide="check" style="width:10px;height:10px;display:inline;"></i>';
|
||
} else if (q.testStatus === 'fail') {
|
||
statusCls = 'qfail';
|
||
statusIcon = '<i data-lucide="x" style="width:10px;height:10px;display:inline;"></i>';
|
||
}
|
||
}
|
||
const latencyInfo = q.tested && q.testLatency ? ` (${q.testLatency}ms)` : '';
|
||
return `<span class="quality-tag ${q.declared ? 'declared' : ''} ${statusCls}"${label}>${statusIcon}${q.quality}${latencyInfo}</span>`;
|
||
}).join('');
|
||
}
|
||
|
||
// QZ API 端点信息
|
||
let qzApiHtml = '';
|
||
if (r.metadata?.qzApiEndpoint?.apiUrl) {
|
||
const qzApi = r.metadata.qzApiEndpoint;
|
||
qzApiHtml = `<div style="margin-bottom:8px;padding:6px 8px;background:var(--bg-secondary);border-radius:6px;font-size:11px;"><i data-lucide="link" style="width:12px;height:12px;display:inline;"></i> <span style="color:var(--text-muted);">QZ API:</span> <span style="color:var(--accent);font-family:monospace;">${truncUrl(qzApi.apiUrl, 60)}</span>${qzApi.apiKey ? ` <span style="color:var(--success);">Key: ${qzApi.apiKey.substring(0,6)}...</span>` : ''}</div>`;
|
||
}
|
||
|
||
const features = r.metadata?.features || [];
|
||
const featuresText = features.length > 0 ? features.slice(0, 5).join(', ') + (features.length > 5 ? ` +${features.length - 5}` : '') : '无';
|
||
const version = r.metadata?.version || '-';
|
||
const author = r.metadata?.author || '-';
|
||
const description = r.metadata?.description || '';
|
||
const dlInfo = r.downloadInfo || {};
|
||
const dlStatus = dlInfo.success ? `<span style="color:var(--success)">${formatSize(dlInfo.size||0)} · ${dlInfo.latency||0}ms</span>` : `<span style="color:var(--danger)">${dlInfo.error || '失败'}</span>`;
|
||
|
||
// 音质通过率
|
||
const qPassRate = r.summary?.details?.qualityPassRate || '0/0';
|
||
const qzApiStatus = r.summary?.details?.qzApiStatus ? `<span style="color:var(--success);font-size:11px;"><i data-lucide="check-circle" style="width:11px;height:11px;display:inline;"></i> QZ API OK</span>` : '';
|
||
|
||
return `<div class="result-card status-${status}" id="result-card-${index}">
|
||
<div class="result-card-header">
|
||
<div class="result-card-title">
|
||
<div class="result-name" title="${name}">${name}</div>
|
||
<div class="result-url" title="${r.url || r.filename}">${r.url || r.filename || ''}</div>
|
||
</div>${scoreBadge}
|
||
</div>
|
||
<div class="platform-tags">${platformTag}${musicTag}${obfTag}</div>
|
||
${description ? `<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;">${escapeHtml(description)}</div>` : ''}
|
||
${qzApiHtml}
|
||
<div class="info-grid">
|
||
<div class="info-item"><span class="info-label">版本</span><span class="info-value">${version}</span></div>
|
||
<div class="info-item"><span class="info-label">作者</span><span class="info-value">${author}</span></div>
|
||
<div class="info-item"><span class="info-label">下载</span><span class="info-value">${dlStatus}</span></div>
|
||
<div class="info-item"><span class="info-label">成功率</span><span class="info-value">${r.summary?.details?.successRate || 'N/A'}</span></div>
|
||
</div>
|
||
${latencyHtml ? `<div style="margin-bottom:12px;"><div class="info-label" style="margin-bottom:6px;">延时测试 ${qzApiStatus}</div>${latencyHtml}</div>` : ''}
|
||
${qualityHtml ? `<div><div class="info-label" style="margin-bottom:4px;">音质支持 <span style="font-size:10px;color:var(--text-muted);">(通过率: ${qPassRate})</span></div><div class="quality-tags">${qualityHtml}</div></div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
// === Markdown 导出 ===
|
||
async function exportMarkdown() {
|
||
if (state.results.length === 0) { toast('没有可导出的测试结果', 'error'); return; }
|
||
try {
|
||
const res = await fetch('/api/export/md', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({results: state.results}) });
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
$('mdPreview').textContent = data.markdown;
|
||
$('mdModal').classList.add('show');
|
||
$('copyMdBtn').onclick = () => { navigator.clipboard.writeText(data.markdown).then(() => toast('已复制到剪贴板','success')).catch(() => toast('复制失败','error')); };
|
||
$('downloadMdBtn').onclick = () => { downloadFile(data.markdown, 'koneko-test-report.md', 'text/markdown'); toast('文件已下载','success'); };
|
||
}
|
||
} catch(err) { toast('导出失败: ' + err.message, 'error'); }
|
||
}
|
||
|
||
// === 海报生成 ===
|
||
function exportPoster() {
|
||
if (state.results.length === 0) { toast('没有可生成海报的测试结果', 'error'); return; }
|
||
generatePoster(state.results);
|
||
$('posterModal').classList.add('show');
|
||
}
|
||
|
||
function generatePoster(results) {
|
||
const canvas = $('poster-canvas'); const ctx = canvas.getContext('2d');
|
||
const headerHeight = 200, cardHeight = 140, footerHeight = 60, padding = 40;
|
||
canvas.width = 800; canvas.height = Math.max(600, headerHeight + results.length * (cardHeight + 12) + footerHeight + padding);
|
||
|
||
const styles = getComputedStyle(document.documentElement);
|
||
const accent = styles.getPropertyValue('--accent').trim() || '#6c5ce7';
|
||
const accentLight = styles.getPropertyValue('--accent-light').trim() || '#a29bfe';
|
||
const textPrimary = styles.getPropertyValue('--text-primary').trim() || '#1a1a2e';
|
||
const textMuted = styles.getPropertyValue('--text-muted').trim() || '#8888a0';
|
||
const success = styles.getPropertyValue('--success').trim() || '#00b894';
|
||
const warning = styles.getPropertyValue('--warning').trim() || '#fdcb6e';
|
||
const danger = styles.getPropertyValue('--danger').trim() || '#e17055';
|
||
const isDark = ['dark','midnight'].includes(state.theme);
|
||
const bgColor = isDark ? '#1a1a2e' : '#f8f9fa';
|
||
const cardBg = isDark ? 'rgba(35,38,54,0.9)' : 'rgba(255,255,255,0.95)';
|
||
const cardBorder = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
|
||
|
||
const bgGradient = ctx.createLinearGradient(0,0,canvas.width,canvas.height);
|
||
bgGradient.addColorStop(0,bgColor); bgGradient.addColorStop(1, isDark ? '#0d0d1a' : '#eef0f5');
|
||
ctx.fillStyle = bgGradient; ctx.fillRect(0,0,canvas.width,canvas.height);
|
||
ctx.globalAlpha = 0.06; ctx.fillStyle = accent;
|
||
ctx.beginPath(); ctx.arc(700,80,120,0,Math.PI*2); ctx.fill();
|
||
ctx.beginPath(); ctx.arc(100,canvas.height-100,80,0,Math.PI*2); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
|
||
const titleGradient = ctx.createLinearGradient(0,0,canvas.width,0);
|
||
titleGradient.addColorStop(0,accent); titleGradient.addColorStop(1,accentLight);
|
||
ctx.fillStyle = titleGradient; roundRect(ctx,padding,35,44,44,10); ctx.fill();
|
||
ctx.fillStyle = '#fff'; ctx.font = 'bold 24px sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
|
||
ctx.fillText('K', padding+22, 57);
|
||
|
||
ctx.fillStyle = titleGradient; ctx.font = 'bold 28px Inter, "Noto Sans SC", sans-serif';
|
||
ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillText('Koneko 音源测试平台', padding+60, 38);
|
||
ctx.fillStyle = textMuted; ctx.font = '14px Inter, "Noto Sans SC", sans-serif';
|
||
ctx.fillText('Lx / QZV2 / MusicFree 插件音质与延时测试报告', padding+60, 72);
|
||
const now = new Date();
|
||
const dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||
ctx.fillStyle = textMuted; ctx.font = '12px Inter, monospace';
|
||
ctx.fillText(`测试时间: ${dateStr} | 插件数量: ${results.length}`, padding+60, 94);
|
||
|
||
ctx.strokeStyle = cardBorder; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(padding,130); ctx.lineTo(canvas.width-padding,130); ctx.stroke();
|
||
|
||
const passed = results.filter(r=>r.overallStatus==='passed').length;
|
||
const wc = results.filter(r=>r.overallStatus==='warning').length;
|
||
const failed = results.filter(r=>r.overallStatus==='failed').length;
|
||
const avgScore = results.length > 0 ? Math.round(results.reduce((s,r)=>s+(r.summary?.score||0),0)/results.length) : 0;
|
||
const statsY = 150, statW = (canvas.width - padding*2 - 24) / 4;
|
||
const stats = [{label:'总插件数',value:results.length,color:accent},{label:'通过',value:passed,color:success},{label:'警告',value:wc,color:warning},{label:'平均分',value:avgScore,color:accentLight}];
|
||
stats.forEach((stat,i) => {
|
||
const x = padding + i*(statW+8);
|
||
ctx.fillStyle = cardBg; roundRect(ctx,x,statsY,statW,56,10); ctx.fill();
|
||
ctx.strokeStyle = cardBorder; roundRect(ctx,x,statsY,statW,56,10); ctx.stroke();
|
||
ctx.fillStyle = stat.color; ctx.font = 'bold 22px Inter, sans-serif'; ctx.textAlign='center'; ctx.textBaseline='top';
|
||
ctx.fillText(stat.value, x+statW/2, statsY+10);
|
||
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
|
||
ctx.fillText(stat.label, x+statW/2, statsY+38);
|
||
});
|
||
|
||
let y = statsY + 76; ctx.textAlign = 'left';
|
||
results.forEach((r,i) => {
|
||
const name = r.metadata?.name || r.filename || truncUrl(r.url) || `插件 #${i+1}`;
|
||
const platform = r.platform || 'unknown';
|
||
const score = r.summary?.score ?? 0;
|
||
const grade = r.summary?.details?.grade ?? 'D';
|
||
const avgLatency = r.summary?.details?.avgLatency ?? -1;
|
||
const qualityStr = r.qualityTests?.map(q=>q.quality).join(', ') || '-';
|
||
|
||
ctx.fillStyle = cardBg; roundRect(ctx,padding,y,canvas.width-padding*2,cardHeight-8,12); ctx.fill();
|
||
ctx.strokeStyle = cardBorder; roundRect(ctx,padding,y,canvas.width-padding*2,cardHeight-8,12); ctx.stroke();
|
||
|
||
let statusColor = textMuted;
|
||
if (r.overallStatus==='passed') statusColor = success;
|
||
else if (r.overallStatus==='warning') statusColor = warning;
|
||
else if (r.overallStatus==='failed') statusColor = danger;
|
||
ctx.fillStyle = statusColor; roundRect(ctx,padding,y,4,cardHeight-8,2); ctx.fill();
|
||
|
||
ctx.fillStyle = textMuted; ctx.font = 'bold 12px Inter, monospace'; ctx.textAlign='left'; ctx.textBaseline='top';
|
||
ctx.fillText(`#${i+1}`, padding+16, y+14);
|
||
ctx.fillStyle = textPrimary; ctx.font = 'bold 15px Inter, "Noto Sans SC", sans-serif';
|
||
ctx.fillText(truncate(name,38), padding+42, y+12);
|
||
|
||
const platformLabels = { lx:'Lx Music', qzv2:'QZV2', musicfree:'MusicFree', unknown:'未知' };
|
||
const platText = platformLabels[platform] || '未知';
|
||
const musicPlat = r.metadata?.musicPlatformName || '';
|
||
const platColor = platform==='lx'?accent:(platform==='qzv2'?success:(platform==='musicfree'?'#e84393':textMuted));
|
||
const platTextW = ctx.measureText(platText).width;
|
||
ctx.fillStyle = platColor+'20'; roundRect(ctx,padding+42,y+34,platTextW+16,20,10); ctx.fill();
|
||
ctx.fillStyle = platColor; ctx.font = '11px Inter, sans-serif';
|
||
ctx.fillText(platText, padding+50, y+38);
|
||
|
||
// 音乐平台标签
|
||
if (musicPlat) {
|
||
const mpTextW = ctx.measureText(musicPlat).width;
|
||
const mpX = padding + 50 + platTextW + 16;
|
||
ctx.fillStyle = '#6c5ce720'; roundRect(ctx, mpX, y+34, mpTextW+16, 20, 10); ctx.fill();
|
||
ctx.fillStyle = '#6c5ce7'; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
|
||
ctx.fillText(musicPlat, mpX+8, y+38);
|
||
ctx.fillStyle = textMuted; ctx.font = '12px Inter, sans-serif';
|
||
ctx.fillText(r.metadata?.version?`v${r.metadata.version}`:'v-', mpX+mpTextW+24, y+38);
|
||
} else {
|
||
ctx.fillStyle = textMuted; ctx.font = '12px Inter, sans-serif';
|
||
ctx.fillText(r.metadata?.version?`v${r.metadata.version}`:'v-', padding+50+platTextW+16, y+38);
|
||
}
|
||
|
||
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
|
||
ctx.fillText('音质:', padding+42, y+64);
|
||
ctx.fillStyle = textPrimary; ctx.font = '11px Inter, monospace';
|
||
ctx.fillText(truncate(qualityStr,35), padding+76, y+64);
|
||
|
||
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
|
||
ctx.fillText('延时:', padding+42, y+82);
|
||
ctx.fillStyle = avgLatency>0?(avgLatency<800?success:(avgLatency<1500?warning:danger)):textMuted;
|
||
ctx.font = 'bold 11px Inter, monospace';
|
||
ctx.fillText(avgLatency>0?`${avgLatency}ms`:'N/A', padding+76, y+82);
|
||
|
||
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
|
||
ctx.fillText('功能:', padding+200, y+82);
|
||
ctx.fillStyle = textPrimary; ctx.font = '11px Inter, monospace';
|
||
ctx.fillText(`${r.metadata?.features?.length||0} 项`, padding+234, y+82);
|
||
|
||
const scoreX = canvas.width-padding-44, scoreY = y+(cardHeight-8)/2, scoreR = 28;
|
||
ctx.beginPath(); ctx.arc(scoreX,scoreY,scoreR,0,Math.PI*2);
|
||
ctx.fillStyle = (grade==='S'||grade==='A')?success+'20':((grade==='B'||grade==='C')?warning+'20':danger+'20'); ctx.fill();
|
||
ctx.strokeStyle = (grade==='S'||grade==='A')?success:((grade==='B'||grade==='C')?warning:danger);
|
||
ctx.lineWidth = 2; ctx.stroke();
|
||
ctx.fillStyle = (grade==='S'||grade==='A')?success:((grade==='B'||grade==='C')?warning:danger);
|
||
ctx.font = 'bold 18px Inter, sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
|
||
ctx.fillText(score, scoreX, scoreY-4);
|
||
ctx.font = 'bold 10px Inter, sans-serif'; ctx.fillText(grade, scoreX, scoreY+12);
|
||
ctx.textAlign='left'; y += cardHeight + 4;
|
||
});
|
||
|
||
y += 8;
|
||
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif'; ctx.textAlign='center';
|
||
ctx.fillText('Koneko 音源测试平台 · Generated by Koneko Music Tester', canvas.width/2, y);
|
||
|
||
$('downloadPosterBtn').onclick = () => {
|
||
const link = document.createElement('a');
|
||
link.download = `koneko-poster-${Date.now()}.png`;
|
||
link.href = canvas.toDataURL('image/png');
|
||
link.click();
|
||
toast('海报已下载', 'success');
|
||
};
|
||
}
|
||
|
||
function roundRect(ctx,x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath()}
|
||
|
||
// === Modals ===
|
||
function initModals() {
|
||
$('mdCloseBtn').addEventListener('click', () => $('mdModal').classList.remove('show'));
|
||
$('posterCloseBtn').addEventListener('click', () => $('posterModal').classList.remove('show'));
|
||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('show'); });
|
||
});
|
||
}
|
||
|
||
// === Toast ===
|
||
function toast(message, type='info') {
|
||
const container = $('toastContainer');
|
||
const icons = { success:'check-circle', error:'x-circle', info:'info' };
|
||
const el = document.createElement('div');
|
||
el.className = `toast ${type}`;
|
||
el.innerHTML = `<i data-lucide="${icons[type]}"></i><span>${message}</span>`;
|
||
container.appendChild(el);
|
||
lucide.createIcons();
|
||
setTimeout(() => { el.style.opacity='0'; el.style.transform='translateX(40px)'; el.style.transition='all 0.3s'; setTimeout(()=>el.remove(),300); }, 3000);
|
||
}
|
||
|
||
// === Utils ===
|
||
function truncUrl(url, max=40){if(!url)return'';if(url.length<=max)return url;return url.substring(0,max-3)+'...'}
|
||
function truncate(str,max){if(!str)return'';if(str.length<=max)return str;return str.substring(0,max-3)+'...'}
|
||
function formatSize(b){if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'KB';return(b/1048576).toFixed(1)+'MB'}
|
||
function downloadFile(content,filename,type){const blob=new Blob([content],{type:type||'text/plain'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=filename;a.click();URL.revokeObjectURL(url)}
|