v1.1: 实时日志系统 + SSE流式输出 + 上传进度 + 平台展示
- 新增 SSE 流式接口 /api/test/stream,测试过程实时推送日志 - 前端日志面板:终端风格,每步骤实时输出(info/success/warn/error) - 文件上传进度条:逐文件读取显示进度百分比 - 平台展示卡片:点击测试时显示所有支持的平台信息 - 新增 /api/platforms 接口返回三大平台完整信息 - 增量渲染:每个插件测试完成后立即显示结果卡片 - 日志支持清空操作 - 六级音质:128k/192k/320k/FLAC/Hi-Res/Master
This commit is contained in:
582
public/js/app.js
Normal file
582
public/js/app.js
Normal file
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* 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,'&').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).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)}
|
||||
Reference in New Issue
Block a user