Files
Koneko Tester 2c6ea2450f v3.0: 逆向分析增强 - QZ插件深度解析器
- 逆向分析 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级
2026-08-04 16:19:35 +08:00

680 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// === 开始测试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)}