Files
koneko-music-tester/public/js/app.js
Koneko Tester 99d813cedf v1.1: 实时日志系统 + SSE流式输出 + 上传进度 + 平台展示
- 新增 SSE 流式接口 /api/test/stream,测试过程实时推送日志
- 前端日志面板:终端风格,每步骤实时输出(info/success/warn/error)
- 文件上传进度条:逐文件读取显示进度百分比
- 平台展示卡片:点击测试时显示所有支持的平台信息
- 新增 /api/platforms 接口返回三大平台完整信息
- 增量渲染:每个插件测试完成后立即显示结果卡片
- 日志支持清空操作
- 六级音质:128k/192k/320k/FLAC/Hi-Res/Master
2026-08-04 14:41:21 +08:00

583 lines
27 KiB
JavaScript
Raw 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 音源测试平台 - 前端逻辑 v1.1
* SSE 实时日志 + 上传进度 + 平台展示
*/
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)$/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.endsWith('.zip') ? '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();
}
// 逐个读取文件,显示进度
async function readFilesWithProgress(files) {
const plugins = [];
const total = files.length;
$('uploadProgress').style.display = 'block';
for (let i = 0; i < total; i++) {
const file = files[i];
const pct = Math.round(((i) / total) * 100);
$('uploadProgressText').textContent = `读取文件 ${i + 1}/${total}: ${file.name}`;
$('uploadProgressPercent').textContent = `${pct}%`;
$('uploadProgressFill').style.width = `${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).replace('<div class="result-card', `<div id="result-card-${index}" class="result-card`);
}
// === 渲染结果 ===
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 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 scoreBadge = `<div class="score-badge grade-${grade}"><div class="score-num">${score}</div><div class="score-grade">${grade}</div></div>`;
let latencyHtml = '';
if (r.latencyTests?.length > 0) {
latencyHtml = r.latencyTests.slice(0, 4).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 => `<span class="quality-tag ${q.declared ? 'declared' : ''}">${q.quality}</span>`).join('');
}
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 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>`;
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}${r.metadata?.sources?.length ? r.metadata.sources.map(s => `<span class="platform-tag tag-lx" style="font-size:11px;">${s.toUpperCase()}</span>`).join('') : ''}</div>
<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;">延时测试</div>${latencyHtml}</div>` : ''}
${qualityHtml ? `<div><div class="info-label" style="margin-bottom:4px;">音质支持</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 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);
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)}