2026-08-04 14:41:21 +08:00
|
|
|
|
/**
|
2026-08-04 16:19:35 +08:00
|
|
|
|
* Koneko 音源测试平台 - 前端逻辑 v2.0
|
2026-08-04 14:41:21 +08:00
|
|
|
|
* SSE 实时日志 + 上传进度 + 平台展示
|
2026-08-04 16:19:35 +08:00
|
|
|
|
* 支持 .qz/.zip/.js 文件上传和服务端解压
|
2026-08-04 14:41:21 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-08-04 16:19:35 +08:00
|
|
|
|
if (file.name.match(/\.(js|zip|qz)$/i)) state.files.push(file);
|
2026-08-04 14:41:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
renderFileList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderFileList() {
|
|
|
|
|
|
const container = $('fileList');
|
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
|
state.files.forEach((file, idx) => {
|
|
|
|
|
|
const chip = document.createElement('div');
|
|
|
|
|
|
chip.className = 'file-chip';
|
2026-08-04 16:19:35 +08:00
|
|
|
|
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>`;
|
2026-08-04 14:41:21 +08:00
|
|
|
|
chip.querySelector('.remove-file').addEventListener('click', () => { state.files.splice(idx, 1); renderFileList(); });
|
|
|
|
|
|
container.appendChild(chip);
|
|
|
|
|
|
});
|
|
|
|
|
|
lucide.createIcons();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 逐个读取文件,显示进度
|
2026-08-04 16:19:35 +08:00
|
|
|
|
// .qz/.zip 文件上传到服务端解压,.js 文件直接读取
|
2026-08-04 14:41:21 +08:00
|
|
|
|
async function readFilesWithProgress(files) {
|
|
|
|
|
|
const plugins = [];
|
|
|
|
|
|
const total = files.length;
|
|
|
|
|
|
$('uploadProgress').style.display = 'block';
|
2026-08-04 16:19:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 先将所有 .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)}%`;
|
2026-08-04 14:41:21 +08:00
|
|
|
|
const code = await readFile(file);
|
|
|
|
|
|
plugins.push({ code, filename: file.name });
|
|
|
|
|
|
await sleep(50);
|
|
|
|
|
|
}
|
2026-08-04 16:19:35 +08:00
|
|
|
|
|
2026-08-04 14:41:21 +08:00
|
|
|
|
$('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);
|
|
|
|
|
|
}
|
2026-08-04 16:19:35 +08:00
|
|
|
|
card.outerHTML = renderResultCard(result, index);
|
2026-08-04 14:41:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 渲染结果 ===
|
|
|
|
|
|
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';
|
2026-08-04 16:19:35 +08:00
|
|
|
|
const musicPlatform = r.metadata?.musicPlatformName || '';
|
|
|
|
|
|
const musicPlatformId = r.metadata?.musicPlatform || '';
|
|
|
|
|
|
const isObf = r.metadata?.isObfuscated;
|
2026-08-04 14:41:21 +08:00
|
|
|
|
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>`;
|
2026-08-04 16:19:35 +08:00
|
|
|
|
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>` : '';
|
2026-08-04 14:41:21 +08:00
|
|
|
|
const scoreBadge = `<div class="score-badge grade-${grade}"><div class="score-num">${score}</div><div class="score-grade">${grade}</div></div>`;
|
|
|
|
|
|
|
2026-08-04 16:19:35 +08:00
|
|
|
|
// 延时测试 - 区分 QZ API 和直连 API
|
2026-08-04 14:41:21 +08:00
|
|
|
|
let latencyHtml = '';
|
|
|
|
|
|
if (r.latencyTests?.length > 0) {
|
2026-08-04 16:19:35 +08:00
|
|
|
|
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 => {
|
2026-08-04 14:41:21 +08:00
|
|
|
|
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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 16:19:35 +08:00
|
|
|
|
// 音质测试 - 带状态指示
|
2026-08-04 14:41:21 +08:00
|
|
|
|
let qualityHtml = '';
|
|
|
|
|
|
if (r.qualityTests?.length > 0) {
|
2026-08-04 16:19:35 +08:00
|
|
|
|
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>`;
|
2026-08-04 14:41:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 || '-';
|
2026-08-04 16:19:35 +08:00
|
|
|
|
const description = r.metadata?.description || '';
|
2026-08-04 14:41:21 +08:00
|
|
|
|
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>`;
|
|
|
|
|
|
|
2026-08-04 16:19:35 +08:00
|
|
|
|
// 音质通过率
|
|
|
|
|
|
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>` : '';
|
|
|
|
|
|
|
2026-08-04 14:41:21 +08:00
|
|
|
|
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>
|
2026-08-04 16:19:35 +08:00
|
|
|
|
<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}
|
2026-08-04 14:41:21 +08:00
|
|
|
|
<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>
|
2026-08-04 16:19:35 +08:00
|
|
|
|
${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>` : ''}
|
2026-08-04 14:41:21 +08:00
|
|
|
|
</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] || '未知';
|
2026-08-04 16:19:35 +08:00
|
|
|
|
const musicPlat = r.metadata?.musicPlatformName || '';
|
2026-08-04 14:41:21 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-08-04 16:19:35 +08:00
|
|
|
|
// 音乐平台标签
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-04 14:41:21 +08:00
|
|
|
|
|
|
|
|
|
|
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)}
|