/** * 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 = `${file.name}`; 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 => `
${p.name} ${p.fullName}
${p.format}
支持音质
${p.qualities.map(q => `${q}`).join('')}
功能
${p.features.slice(0,6).map(f => `${f}`).join('')}${p.features.length > 6 ? `+${p.features.length - 6}` : ''}
检测特征
${p.detectionHints.map(h => `${h}`).join('')}
`).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 = `${time}${level.toUpperCase()}${escapeHtml(message)}`; panel.appendChild(entry); panel.scrollTop = panel.scrollHeight; } function escapeHtml(str) { return str.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 = '
测试中...'; $('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 = ' 开始测试'; 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 = '

没有测试结果

'; 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 = `${r.platformLabel || '未知'}`; const musicTag = musicPlatform ? `${musicPlatform}` : ''; const obfTag = isObf ? `混淆` : ''; const scoreBadge = `
${score}
${grade}
`; // 延时测试 - 区分 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 += `
QZ API ${basicTest.success ? basicTest.latency + 'ms' : '不通'}
`; } 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 `
${label}
${t.success ? t.latency + 'ms' : '失败'}
`; }).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 = ''; } else if (q.testStatus === 'fail') { statusCls = 'qfail'; statusIcon = ''; } } const latencyInfo = q.tested && q.testLatency ? ` (${q.testLatency}ms)` : ''; return `${statusIcon}${q.quality}${latencyInfo}`; }).join(''); } // QZ API 端点信息 let qzApiHtml = ''; if (r.metadata?.qzApiEndpoint?.apiUrl) { const qzApi = r.metadata.qzApiEndpoint; qzApiHtml = `
QZ API: ${truncUrl(qzApi.apiUrl, 60)}${qzApi.apiKey ? ` Key: ${qzApi.apiKey.substring(0,6)}...` : ''}
`; } 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 ? `${formatSize(dlInfo.size||0)} · ${dlInfo.latency||0}ms` : `${dlInfo.error || '失败'}`; // 音质通过率 const qPassRate = r.summary?.details?.qualityPassRate || '0/0'; const qzApiStatus = r.summary?.details?.qzApiStatus ? ` QZ API OK` : ''; return `
${name}
${r.url || r.filename || ''}
${scoreBadge}
${platformTag}${musicTag}${obfTag}
${description ? `
${escapeHtml(description)}
` : ''} ${qzApiHtml}
版本${version}
作者${author}
下载${dlStatus}
成功率${r.summary?.details?.successRate || 'N/A'}
${latencyHtml ? `
延时测试 ${qzApiStatus}
${latencyHtml}
` : ''} ${qualityHtml ? `
音质支持 (通过率: ${qPassRate})
${qualityHtml}
` : ''}
`; } // === 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 = `${message}`; 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)}