From 99d813cedf80185fb497b97fd006a571be7f5e07 Mon Sep 17 00:00:00 2001 From: Koneko Tester Date: Tue, 4 Aug 2026 14:41:21 +0800 Subject: [PATCH] =?UTF-8?q?v1.1:=20=E5=AE=9E=E6=97=B6=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20+=20SSE=E6=B5=81=E5=BC=8F=E8=BE=93?= =?UTF-8?q?=E5=87=BA=20+=20=E4=B8=8A=E4=BC=A0=E8=BF=9B=E5=BA=A6=20+=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SSE 流式接口 /api/test/stream,测试过程实时推送日志 - 前端日志面板:终端风格,每步骤实时输出(info/success/warn/error) - 文件上传进度条:逐文件读取显示进度百分比 - 平台展示卡片:点击测试时显示所有支持的平台信息 - 新增 /api/platforms 接口返回三大平台完整信息 - 增量渲染:每个插件测试完成后立即显示结果卡片 - 日志支持清空操作 - 六级音质:128k/192k/320k/FLAC/Hi-Res/Master --- .gitignore | 5 + lib/detector.js | 198 +++++++++++ lib/tester.js | 263 ++++++++++++++ package-lock.json | 828 +++++++++++++++++++++++++++++++++++++++++++ package.json | 13 + public/css/style.css | 381 ++++++++++++++++++++ public/index.html | 194 ++++++++++ public/js/app.js | 582 ++++++++++++++++++++++++++++++ server.js | 219 ++++++++++++ 9 files changed, 2683 insertions(+) create mode 100644 .gitignore create mode 100644 lib/detector.js create mode 100644 lib/tester.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/css/style.css create mode 100644 public/index.html create mode 100644 public/js/app.js create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3543a00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +npm-debug.log* +.DS_Store +*.log +.env diff --git a/lib/detector.js b/lib/detector.js new file mode 100644 index 0000000..de2a2cf --- /dev/null +++ b/lib/detector.js @@ -0,0 +1,198 @@ +/** + * 插件格式自动检测模块 + * 支持检测: Lx Music / QZV2 / MusicFree 三种插件格式 + */ + +const PLATFORMS = { + LX: 'lx', + QZV2: 'qzv2', + MUSICFREE: 'musicfree', + UNKNOWN: 'unknown' +}; + +const PLATFORM_LABELS = { + lx: '落雪音乐 (Lx Music)', + qzv2: '清泽音乐 (QZV2)', + musicfree: 'MusicFree', + unknown: '未知格式' +}; + +/** 所有支持的平台信息(用于前端展示) */ +const ALL_PLATFORMS = [ + { + id: 'lx', + name: '落雪音乐', + fullName: 'Lx Music', + format: 'JavaScript 脚本', + qualities: ['128k', '192k', '320k', 'flac', 'flac24bit', 'master'], + features: ['musicUrl', 'lyric', 'pic'], + sources: ['kw', 'kg', 'tx', 'wy', 'mg', 'local'], + detectionHints: ['globalThis.lx', 'EVENT_NAMES', '@name 注释头'], + docUrl: 'https://lxmusic.toside.cn/desktop/custom-source' + }, + { + id: 'qzv2', + name: '清泽音乐', + fullName: 'QZ Music v2', + format: 'JS 模块 (ZIP/JS)', + qualities: ['low', 'standard', 'exhigh', 'high', 'lossless', 'hires', 'master'], + features: ['search', 'getUrl', 'getLyric', 'getPlaylist', 'getAlbum'], + sources: ['自定义'], + detectionHints: ['pluginInfo', 'getUrl', 'supportFunc'], + docUrl: 'https://music.qz.shiqianjiang.cn/' + }, + { + id: 'musicfree', + name: 'MusicFree', + fullName: 'MusicFree', + format: 'CommonJS 模块', + qualities: ['low', 'standard', 'high', 'super', 'hires', 'master'], + features: ['search', 'getMediaSource', 'getMusicInfo', 'getLyric', 'getAlbumInfo', 'getMusicSheetInfo', 'getArtistWorks', 'getTopLists', 'getRecommendSheetTags', 'getMusicComments'], + sources: ['自定义'], + detectionHints: ['platform:', 'getMediaSource', 'supportedSearchType'], + docUrl: 'https://musicfree.catcat.work/plugin/protocol.html' + } +]; + +/** + * 检测插件格式 + */ +function detectPlatform(code, filename = '') { + const scores = { lx: 0, qzv2: 0, musicfree: 0 }; + + // === Lx Music === + if (/\/\*\*[\s\S]*?@name\s+/m.test(code)) scores.lx += 2; + if (/globalThis\.lx\b/.test(code)) scores.lx += 3; + if (/EVENT_NAMES/.test(code)) scores.lx += 2; + if (/send\s*\(\s*EVENT_NAMES\.inited/.test(code)) scores.lx += 3; + if (/lx\.(request|on|send)\b/.test(code)) scores.lx += 2; + if (/['"`]128k['"`]|['"`]320k['"`]|['"`]flac24bit['"`]/.test(code)) scores.lx += 1; + if (/sources\s*:\s*\{/.test(code) && /qualitys/.test(code)) scores.lx += 2; + + // === QZV2 === + if (/pluginInfo\s*[:?]/.test(code)) scores.qzv2 += 3; + if (/getUrl\s*[:(]/.test(code)) scores.qzv2 += 2; + if (/supportFunc/.test(code)) scores.qzv2 += 2; + if (/getPlay(list|List)\s*[:(]/.test(code)) scores.qzv2 += 2; + if (/musicSearch/.test(code)) scores.qzv2 += 2; + if (/info\s*:\s*\{[\s\S]*?id\s*:/.test(code) && /info\s*:\s*\{[\s\S]*?name\s*:/.test(code)) scores.qzv2 += 1; + if (/allPage/.test(code)) scores.qzv2 += 1; + + // === MusicFree === + if (/platform\s*:\s*["'`]/.test(code)) scores.musicfree += 2; + if (/getMediaSource\s*[:(]/.test(code)) scores.musicfree += 3; + if (/getMusicInfo\s*[:(]/.test(code)) scores.musicfree += 2; + if (/getAlbumInfo|getMusicSheetInfo/.test(code)) scores.musicfree += 2; + if (/supportedSearchType/.test(code)) scores.musicfree += 2; + if (/cacheControl/.test(code)) scores.musicfree += 1; + if (/srcUrl/.test(code)) scores.musicfree += 1; + if (/primaryKey/.test(code)) scores.musicfree += 1; + if (/getTopLists|getRecommendSheetTags/.test(code)) scores.musicfree += 2; + + // 排除规则 + if (scores.musicfree >= 3 && scores.qzv2 > 0) { + if (/getMediaSource/.test(code) && !/pluginInfo/.test(code)) { + scores.qzv2 = Math.max(0, scores.qzv2 - 2); + } + } + + let bestPlatform = PLATFORMS.UNKNOWN; + let bestScore = 0; + for (const [platform, score] of Object.entries(scores)) { + if (score > bestScore) { bestScore = score; bestPlatform = platform; } + } + if (bestScore < 2) bestPlatform = PLATFORMS.UNKNOWN; + + const metadata = extractMetadata(code, bestPlatform, filename); + return { platform: bestPlatform, platformLabel: PLATFORM_LABELS[bestPlatform], metadata, confidence: bestScore, scores }; +} + +function extractMetadata(code, platform, filename) { + const metadata = { + name: '', version: '', author: '', description: '', homepage: '', + quality: [], features: [], sources: [], apiUrls: [] + }; + + const urlMatches = code.match(/https?:\/\/[^\s"'`<>)}\]]+/g) || []; + metadata.apiUrls = [...new Set(urlMatches)].slice(0, 20); + + if (platform === 'lx') { + const nameMatch = code.match(/@name\s+(.+)/); if (nameMatch) metadata.name = nameMatch[1].trim(); + const descMatch = code.match(/@description\s+(.+)/); if (descMatch) metadata.description = descMatch[1].trim(); + const versionMatch = code.match(/@version\s+(.+)/); if (versionMatch) metadata.version = versionMatch[1].trim(); + const authorMatch = code.match(/@author\s+(.+)/); if (authorMatch) metadata.author = authorMatch[1].trim(); + const homeMatch = code.match(/@homepage\s+(.+)/); if (homeMatch) metadata.homepage = homeMatch[1].trim(); + + const qualitySet = new Set(); + if (/['"`]128k['"`]/.test(code)) qualitySet.add('128k'); + if (/['"`]192k['"`]/.test(code)) qualitySet.add('192k'); + if (/['"`]320k['"`]/.test(code)) qualitySet.add('320k'); + if (/['"`]flac24bit['"`]/.test(code)) qualitySet.add('flac24bit'); + if (/['"`]flac['"`]/.test(code)) qualitySet.add('flac'); + if (/['"`]hires['"`]/.test(code)) qualitySet.add('hires'); + if (/['"`]master['"`]/.test(code)) qualitySet.add('master'); + metadata.quality = [...qualitySet]; + + if (/musicUrl/.test(code)) metadata.features.push('musicUrl'); + if (/lyric/.test(code)) metadata.features.push('lyric'); + if (/pic/.test(code)) metadata.features.push('pic'); + + const sourcePattern = /\b(kw|kg|tx|wy|mg|local)\b\s*:/g; + let match; const sourceSet = new Set(); + while ((match = sourcePattern.exec(code)) !== null) sourceSet.add(match[1]); + metadata.sources = [...sourceSet]; + + } else if (platform === 'qzv2') { + const nameMatch = code.match(/name\s*:\s*["'`]([^"'`]+)["'`]/); if (nameMatch) metadata.name = nameMatch[1]; + const idMatch = code.match(/id\s*:\s*["'`]([^"'`]+)["'`]/); if (idMatch) metadata.description = `ID: ${idMatch[1]}`; + const descMatch = code.match(/description\s*:\s*["'`]([^"'`]+)["'`]/); if (descMatch) metadata.description = descMatch[1]; + const versionMatch = code.match(/version\s*:\s*["'`]?(["'`]?)([\d.]+)/); if (versionMatch) metadata.version = versionMatch[2]; + + const qualityMatch = code.match(/quality\s*:\s*\[([^\]]+)\]/); + if (qualityMatch) { + const qItems = qualityMatch[1].match(/["'`]([^"'`]+)["'`]/g); + if (qItems) metadata.quality = qItems.map(q => q.replace(/["'`]/g, '')); + } + + const supportFuncMatch = code.match(/supportFunc\s*:\s*\[([^\]]+)\]/); + if (supportFuncMatch) { + const fItems = supportFuncMatch[1].match(/["'`]([^"'`]+)["'`]/g); + if (fItems) metadata.features = fItems.map(f => f.replace(/["'`]/g, '')); + } + if (/getUrl/.test(code) && !metadata.features.includes('getUrl')) metadata.features.push('getUrl'); + if (/getLyric/.test(code) && !metadata.features.includes('getLyric')) metadata.features.push('getLyric'); + if (/search/.test(code) && !metadata.features.includes('search')) metadata.features.push('search'); + + } else if (platform === 'musicfree') { + const platformMatch = code.match(/platform\s*:\s*["'`]([^"'`]+)["'`]/); if (platformMatch) metadata.name = platformMatch[1]; + const versionMatch = code.match(/version\s*:\s*["'`]([^"'`]+)["'`]/); if (versionMatch) metadata.version = versionMatch[1]; + const authorMatch = code.match(/author\s*:\s*["'`]([^"'`]+)["'`]/); if (authorMatch) metadata.author = authorMatch[1]; + const srcUrlMatch = code.match(/srcUrl\s*:\s*["'`]([^"'`]+)["'`]/); if (srcUrlMatch) metadata.homepage = srcUrlMatch[1]; + + const qualitySet = new Set(); + if (/['"`]low['"`]/.test(code)) qualitySet.add('low'); + if (/['"`]standard['"`]/.test(code)) qualitySet.add('standard'); + if (/['"`]high['"`]/.test(code)) qualitySet.add('high'); + if (/['"`]super['"`]/.test(code)) qualitySet.add('super'); + if (/['"`]hires['"`]/.test(code)) qualitySet.add('hires'); + if (/['"`]master['"`]/.test(code)) qualitySet.add('master'); + metadata.quality = [...qualitySet]; + + const featureMap = [ + ['search', 'search'], ['getMediaSource', 'getMediaSource'], ['getMusicInfo', 'getMusicInfo'], + ['getLyric', 'getLyric'], ['getAlbumInfo', 'getAlbumInfo'], ['getMusicSheetInfo', 'getMusicSheetInfo'], + ['getArtistWorks', 'getArtistWorks'], ['importMusicItem', 'importMusicItem'], + ['importMusicSheet', 'importMusicSheet'], ['getTopLists', 'getTopLists'], + ['getTopListDetail', 'getTopListDetail'], ['getRecommendSheetTags', 'getRecommendSheetTags'], + ['getRecommendSheetsByTag', 'getRecommendSheetsByTag'], ['getMusicComments', 'getMusicComments'] + ]; + for (const [pattern, label] of featureMap) { + if (new RegExp(pattern).test(code)) metadata.features.push(label); + } + } + + if (!metadata.name && filename) metadata.name = filename.replace(/\.(js|zip)$/i, ''); + return metadata; +} + +module.exports = { detectPlatform, PLATFORMS, PLATFORM_LABELS, ALL_PLATFORMS }; diff --git a/lib/tester.js b/lib/tester.js new file mode 100644 index 0000000..ba12ea2 --- /dev/null +++ b/lib/tester.js @@ -0,0 +1,263 @@ +/** + * 插件测试模块 - 支持日志回调 + * 每个步骤都会通过 logger 回调实时输出 + */ + +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); +const { detectPlatform, PLATFORMS } = require('./detector'); + +function fetchPluginCode(url, timeout = 15000) { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const client = parsedUrl.protocol === 'https:' ? https : http; + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers: { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/1.1', 'Accept': '*/*' }, + timeout + }; + const req = client.request(options, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return fetchPluginCode(new URL(res.headers.location, url).href, timeout).then(resolve).catch(reject); + } + if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`)); + let data = ''; let dataBytes = Buffer.alloc(0); + res.on('data', (chunk) => { typeof chunk === 'string' ? data += chunk : dataBytes = Buffer.concat([dataBytes, chunk]); }); + res.on('end', () => { + const content = data || dataBytes.toString('utf-8'); + resolve({ code: content, statusCode: res.statusCode, headers: res.headers, size: Buffer.byteLength(content) }); + }); + }); + req.on('timeout', () => { req.destroy(); reject(new Error('下载超时')); }); + req.on('error', reject); + req.end(); + }); +} + +function testUrlLatency(url, timeout = 10000) { + return new Promise((resolve) => { + const startTime = Date.now(); let resolved = false; + const finish = (result) => { if (!resolved) { resolved = true; resolve(result); } }; + try { + const parsedUrl = new URL(url); + const client = parsedUrl.protocol === 'https:' ? https : http; + const options = { + hostname: parsedUrl.hostname, port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, method: 'GET', + headers: { 'User-Agent': 'Mozilla/5.0 Koneko-Music-Tester/1.1', 'Accept': '*/*', 'Range': 'bytes=0-1023' }, + timeout + }; + const req = client.request(options, (res) => { + const latency = Date.now() - startTime; + let bodySize = 0; + res.on('data', (chunk) => { bodySize += chunk.length; if (bodySize > 4096) res.destroy(); }); + res.on('end', () => finish({ url, status: res.statusCode, latency, success: res.statusCode >= 200 && res.statusCode < 400, contentType: res.headers['content-type'] || '', contentLength: parseInt(res.headers['content-length'] || '0', 10), error: null })); + res.on('error', () => finish({ url, status: res.statusCode, latency, success: false, contentType: '', contentLength: 0, error: '响应读取错误' })); + }); + req.on('timeout', () => { req.destroy(); finish({ url, status: 0, latency: timeout, success: false, contentType: '', contentLength: 0, error: '请求超时' }); }); + req.on('error', (err) => finish({ url, status: 0, latency: Date.now() - startTime, success: false, contentType: '', contentLength: 0, error: err.message })); + req.end(); + } catch (err) { + finish({ url, status: 0, latency: 0, success: false, contentType: '', contentLength: 0, error: err.message }); + } + }); +} + +/** + * 测试插件主入口 - 带日志回调 + * @param {string} url + * @param {string} customCode + * @param {string} filename + * @param {function} logger - 日志回调 (level, message, data) + */ +async function testPlugin(url, customCode = null, filename = '', logger = null) { + const log = (level, message, data) => { if (logger) logger(level, message, data); }; + const result = { + url: url || filename, filename, + platform: 'unknown', platformLabel: '未知格式', + metadata: {}, downloadInfo: null, latencyTests: [], qualityTests: [], + overallStatus: 'pending', summary: {}, testedAt: new Date().toISOString() + }; + + let code = customCode; + + // 步骤1: 下载/读取 + log('info', `开始处理: ${filename || url || '未知'}`); + if (!code && url) { + log('info', `正在下载插件文件...`); + try { + const downloadStart = Date.now(); + const response = await fetchPluginCode(url); + const downloadLatency = Date.now() - downloadStart; + code = response.code; + result.downloadInfo = { success: true, latency: downloadLatency, size: response.size, statusCode: response.statusCode, contentType: response.headers['content-type'] || '' }; + log('success', `下载完成 | 大小: ${formatSize(response.size)} | 耗时: ${downloadLatency}ms`); + } catch (err) { + result.downloadInfo = { success: false, latency: 0, error: err.message }; + result.overallStatus = 'failed'; + result.summary = { error: `插件下载失败: ${err.message}`, score: 0 }; + log('error', `下载失败: ${err.message}`); + return result; + } + } else if (code) { + result.downloadInfo = { success: true, latency: 0, size: Buffer.byteLength(code), statusCode: 0, contentType: 'text/plain' }; + log('success', `文件已读取 | 大小: ${formatSize(Buffer.byteLength(code))}`); + } + + if (!code) { + result.overallStatus = 'failed'; + result.summary = { error: '无插件代码', score: 0 }; + log('error', '无插件代码'); + return result; + } + + // 步骤2: 格式检测 + log('info', `正在识别插件格式...`); + const detection = detectPlatform(code, filename); + result.platform = detection.platform; + result.platformLabel = detection.platformLabel; + result.metadata = detection.metadata; + result.detectionScores = detection.scores; + + if (detection.platform !== 'unknown') { + log('success', `格式识别: ${detection.platformLabel} (置信度: ${detection.confidence})`); + if (detection.metadata.name) log('info', `插件名称: ${detection.metadata.name}`); + if (detection.metadata.version) log('info', `版本: ${detection.metadata.version}`); + if (detection.metadata.author) log('info', `作者: ${detection.metadata.author}`); + } else { + log('warn', `未能识别插件格式,得分: lx=${detection.scores.lx}, qzv2=${detection.scores.qzv2}, musicfree=${detection.scores.musicfree}`); + } + + // 步骤3: 功能分析 + if (detection.metadata.features && detection.metadata.features.length > 0) { + log('info', `功能列表: ${detection.metadata.features.join(', ')}`); + } + if (detection.metadata.sources && detection.metadata.sources.length > 0) { + log('info', `音源: ${detection.metadata.sources.join(', ').toUpperCase()}`); + } + + // 步骤4: 延时测试 + const apiUrls = detection.metadata.apiUrls || []; + const testUrls = apiUrls + .filter(u => !u.match(/\.(png|jpg|jpeg|gif|svg|ico|css|woff|ttf)$/i)) + .filter(u => !u.includes('catcat.work')) + .filter(u => !u.includes('w3.org')) + .filter(u => !u.includes('schema.org')) + .slice(0, 5); + + if (url && !customCode) { + log('info', `测试插件源 URL 可达性...`); + const pluginUrlTest = await testUrlLatency(url); + result.latencyTests.push({ ...pluginUrlTest, isPluginUrl: true }); + log(pluginUrlTest.success ? 'success' : 'warn', `插件源 URL | 状态: ${pluginUrlTest.status} | 延时: ${pluginUrlTest.latency}ms`); + } + + if (testUrls.length > 0) { + log('info', `开始延时测试,共 ${testUrls.length} 个 API 端点...`); + for (let i = 0; i < testUrls.length; i++) { + const testUrl = testUrls[i]; + log('info', `[${i + 1}/${testUrls.length}] 测试: ${truncate(testUrl, 60)}`); + } + const latencyPromises = testUrls.map(u => testUrlLatency(u)); + const latencyResults = await Promise.all(latencyPromises); + result.latencyTests.push(...latencyResults); + const successCount = latencyResults.filter(t => t.success).length; + if (successCount > 0) { + const avgLatency = Math.round(latencyResults.filter(t => t.success).reduce((sum, t) => sum + t.latency, 0) / successCount); + log('success', `延时测试完成 | 成功: ${successCount}/${testUrls.length} | 平均延时: ${avgLatency}ms`); + } else { + log('warn', `延时测试完成 | 所有端点均不可达`); + } + } else if (!url || customCode) { + log('info', `未发现可测试的 API 端点`); + } + + // 步骤5: 音质分析 + const declaredQualities = detection.metadata.quality || []; + const qualityMap = getQualityMap(detection.platform); + result.qualityTests = declaredQualities.map(q => ({ quality: q, label: qualityMap[q] || q, declared: true, testable: testUrls.length > 0 })); + + if (result.qualityTests.length === 0 && detection.platform !== 'unknown') { + const defaults = getDefaultQualities(detection.platform); + result.qualityTests = defaults.map(q => ({ quality: q, label: qualityMap[q] || q, declared: false, testable: testUrls.length > 0 })); + log('info', `未声明音质,使用默认音质列表: ${defaults.join(', ')}`); + } else if (declaredQualities.length > 0) { + log('success', `支持音质: ${declaredQualities.map(q => `${q}(${qualityMap[q] || q})`).join(', ')}`); + } + + // 步骤6: 评分 + log('info', `正在计算综合评分...`); + result.summary = calculateSummary(result); + result.overallStatus = result.summary.score >= 60 ? 'passed' : (result.summary.score > 0 ? 'warning' : 'failed'); + log('success', `测试完成 | 评分: ${result.summary.score}/100 | 等级: ${result.summary.details?.grade || 'D'} | 状态: ${result.overallStatus === 'passed' ? '通过' : (result.overallStatus === 'warning' ? '警告' : '失败')}`); + + return result; +} + +function getQualityMap(platform) { + const maps = { + lx: { '128k': '标准 128kbps', '192k': '标准 192kbps', '320k': '高品质 320kbps', 'flac': '无损 FLAC', 'flac24bit': 'Hi-Res FLAC 24bit', 'hires': 'Hi-Res', 'master': '母带 Master' }, + qzv2: { 'low': '低音质', 'standard': '标准音质', 'exhigh': '极高音质', 'high': '高音质', 'lossless': '无损音质', 'hires': 'Hi-Res', 'master': '母带 Master' }, + musicfree: { 'low': '低音质', 'standard': '标准音质', 'high': '高音质', 'super': '超高音质', 'hires': 'Hi-Res', 'master': '母带 Master' } + }; + return maps[platform] || {}; +} + +function getDefaultQualities(platform) { + const defaults = { + lx: ['128k', '192k', '320k', 'flac', 'flac24bit', 'master'], + qzv2: ['standard', 'exhigh', 'high', 'lossless', 'hires', 'master'], + musicfree: ['low', 'standard', 'high', 'super', 'hires', 'master'] + }; + return defaults[platform] || []; +} + +function calculateSummary(result) { + let score = 0; const details = {}; + if (result.downloadInfo && result.downloadInfo.success) { score += 20; details.download = '成功'; } + else if (result.downloadInfo && !result.downloadInfo.success) { details.download = '失败'; return { score: 0, details, error: result.downloadInfo.error }; } + else { score += 20; details.download = '文件上传'; } + + if (result.platform !== 'unknown') { score += 20; details.format = result.platformLabel; } else { details.format = '未识别'; } + + const successLatency = result.latencyTests.filter(t => t.success); + if (result.latencyTests.length > 0) { + if (successLatency.length > 0) { + const avgLatency = successLatency.reduce((sum, t) => sum + t.latency, 0) / successLatency.length; + details.avgLatency = Math.round(avgLatency); details.successRate = `${successLatency.length}/${result.latencyTests.length}`; + if (avgLatency < 300) score += 30; else if (avgLatency < 800) score += 25; else if (avgLatency < 1500) score += 18; else if (avgLatency < 3000) score += 10; else score += 5; + } else { details.avgLatency = -1; details.successRate = `0/${result.latencyTests.length}`; } + } else { score += 15; details.avgLatency = -1; details.successRate = 'N/A'; } + + if (result.qualityTests.length > 0) { + const declaredCount = result.qualityTests.filter(q => q.declared).length; + score += Math.min(15, declaredCount * 5); + details.qualityCount = result.qualityTests.length; details.qualities = result.qualityTests.map(q => q.quality).join(', '); + } else { details.qualityCount = 0; } + + if (result.metadata.features && result.metadata.features.length > 0) { + score += Math.min(15, result.metadata.features.length * 3); + details.features = result.metadata.features.join(', '); + } + + details.score = Math.min(100, score); + if (details.score >= 85) details.grade = 'S'; else if (details.score >= 70) details.grade = 'A'; + else if (details.score >= 55) details.grade = 'B'; else if (details.score >= 40) details.grade = 'C'; else details.grade = 'D'; + return { score: Math.min(100, score), details }; +} + +function formatSize(bytes) { + if (bytes < 1024) return bytes + 'B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + 'KB'; + return (bytes / 1048576).toFixed(1) + 'MB'; +} + +function truncate(str, max) { + if (!str) return ''; if (str.length <= max) return str; return str.substring(0, max - 3) + '...'; +} + +module.exports = { testPlugin, fetchPluginCode, testUrlLatency }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3729168 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,828 @@ +{ + "name": "koneko-music-tester", + "version": "1.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "koneko-music-tester", + "version": "1.1.0", + "dependencies": { + "express": "^4.18.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1af6a9 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "koneko-music-tester", + "version": "1.1.0", + "description": "Koneko 音源测试平台 - Lx/QZV2/MusicFree 插件音质延时测试", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "express": "^4.18.2" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..f24a081 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,381 @@ +*,*::before,*::after{margin:0;padding:0;box-sizing:border-box} + +:root{ + --font-sans:'Inter','Noto Sans SC',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; + --font-mono:'JetBrains Mono','Fira Code','Consolas',monospace; + --radius-sm:8px;--radius-md:12px;--radius-lg:20px;--radius-xl:28px; + --transition:0.3s cubic-bezier(0.4,0,0.2,1); + --bg-image:url('https://t.alcy.cc/moez'); +} + +[data-theme="light"]{ + --bg-primary:rgba(255,255,255,0.75);--bg-secondary:rgba(255,255,255,0.85); + --bg-tertiary:rgba(248,250,252,0.9);--bg-card:rgba(255,255,255,0.8); + --bg-hover:rgba(0,0,0,0.04);--bg-input:rgba(255,255,255,0.9); + --text-primary:#1a1a2e;--text-secondary:#4a4a6a;--text-muted:#8888a0; + --border-color:rgba(0,0,0,0.08);--border-strong:rgba(0,0,0,0.12); + --accent:#6c5ce7;--accent-light:#a29bfe;--accent-bg:rgba(108,92,231,0.1); + --success:#00b894;--success-bg:rgba(0,184,148,0.1); + --warning:#fdcb6e;--warning-bg:rgba(253,203,110,0.15); + --danger:#e17055;--danger-bg:rgba(225,112,85,0.1); + --shadow-sm:0 2px 8px rgba(0,0,0,0.06);--shadow-md:0 4px 20px rgba(0,0,0,0.08); + --shadow-lg:0 8px 40px rgba(0,0,0,0.12); + --glass-blur:blur(20px);--text-on-accent:#fff; + --tag-lx-bg:rgba(108,92,231,0.12);--tag-lx-text:#6c5ce7; + --tag-qzv2-bg:rgba(0,184,148,0.12);--tag-qzv2-text:#00b894; + --tag-musicfree-bg:rgba(253,121,168,0.12);--tag-musicfree-text:#e84393; + --tag-unknown-bg:rgba(100,100,120,0.12);--tag-unknown-text:#646478; + --log-bg:rgba(20,22,34,0.92);--log-text:#e8e8f0;--log-muted:#7878a0; +} + +[data-theme="dark"]{ + --bg-primary:rgba(20,22,34,0.8);--bg-secondary:rgba(28,30,44,0.85); + --bg-tertiary:rgba(35,38,54,0.9);--bg-card:rgba(30,32,48,0.75); + --bg-hover:rgba(255,255,255,0.06);--bg-input:rgba(20,22,34,0.9); + --text-primary:#e8e8f0;--text-secondary:#b0b0c8;--text-muted:#7878a0; + --border-color:rgba(255,255,255,0.08);--border-strong:rgba(255,255,255,0.15); + --accent:#a29bfe;--accent-light:#6c5ce7;--accent-bg:rgba(162,155,254,0.15); + --success:#55efc4;--success-bg:rgba(85,239,196,0.12); + --warning:#ffeaa7;--warning-bg:rgba(255,234,167,0.12); + --danger:#fab1a0;--danger-bg:rgba(250,177,160,0.12); + --shadow-sm:0 2px 8px rgba(0,0,0,0.3);--shadow-md:0 4px 20px rgba(0,0,0,0.4); + --shadow-lg:0 8px 40px rgba(0,0,0,0.5); + --glass-blur:blur(20px);--text-on-accent:#1a1a2e; + --tag-lx-bg:rgba(162,155,254,0.18);--tag-lx-text:#a29bfe; + --tag-qzv2-bg:rgba(85,239,196,0.18);--tag-qzv2-text:#55efc4; + --tag-musicfree-bg:rgba(255,121,168,0.18);--tag-musicfree-text:#ff7eb6; + --tag-unknown-bg:rgba(150,150,170,0.18);--tag-unknown-text:#a0a0b8; + --log-bg:rgba(10,12,20,0.95);--log-text:#e8e8f0;--log-muted:#585870; +} + +[data-theme="sakura"]{ + --bg-primary:rgba(255,240,245,0.75);--bg-secondary:rgba(255,245,248,0.85); + --bg-tertiary:rgba(252,235,240,0.9);--bg-card:rgba(255,245,248,0.8); + --bg-hover:rgba(255,182,193,0.08);--bg-input:rgba(255,250,252,0.9); + --text-primary:#5a2a3a;--text-secondary:#8a4a5a;--text-muted:#b08090; + --border-color:rgba(220,100,130,0.12);--border-strong:rgba(220,100,130,0.2); + --accent:#ff6b9d;--accent-light:#ffa3c4;--accent-bg:rgba(255,107,157,0.1); + --success:#2ecc71;--success-bg:rgba(46,204,113,0.1); + --warning:#f39c12;--warning-bg:rgba(243,156,18,0.12); + --danger:#e74c3c;--danger-bg:rgba(231,76,60,0.1); + --shadow-sm:0 2px 8px rgba(220,100,130,0.1);--shadow-md:0 4px 20px rgba(220,100,130,0.12); + --shadow-lg:0 8px 40px rgba(220,100,130,0.18); + --glass-blur:blur(20px);--text-on-accent:#fff; + --tag-lx-bg:rgba(255,107,157,0.12);--tag-lx-text:#ff6b9d; + --tag-qzv2-bg:rgba(46,204,113,0.12);--tag-qzv2-text:#2ecc71; + --tag-musicfree-bg:rgba(155,89,182,0.12);--tag-musicfree-text:#9b59b6; + --tag-unknown-bg:rgba(100,100,120,0.12);--tag-unknown-text:#646478; + --log-bg:rgba(40,20,30,0.95);--log-text:#f0d8e0;--log-muted:#9a6a7a; +} + +[data-theme="ocean"]{ + --bg-primary:rgba(230,245,255,0.75);--bg-secondary:rgba(240,249,255,0.85); + --bg-tertiary:rgba(220,240,255,0.9);--bg-card:rgba(240,249,255,0.8); + --bg-hover:rgba(0,150,200,0.06);--bg-input:rgba(250,253,255,0.9); + --text-primary:#0a3d5c;--text-secondary:#1a5d7c;--text-muted:#508da0; + --border-color:rgba(0,120,180,0.1);--border-strong:rgba(0,120,180,0.18); + --accent:#0984e3;--accent-light:#74b9ff;--accent-bg:rgba(9,132,227,0.1); + --success:#00cec9;--success-bg:rgba(0,206,201,0.1); + --warning:#fdcb6e;--warning-bg:rgba(253,203,110,0.12); + --danger:#ff7675;--danger-bg:rgba(255,118,117,0.1); + --shadow-sm:0 2px 8px rgba(0,120,180,0.1);--shadow-md:0 4px 20px rgba(0,120,180,0.12); + --shadow-lg:0 8px 40px rgba(0,120,180,0.18); + --glass-blur:blur(20px);--text-on-accent:#fff; + --tag-lx-bg:rgba(9,132,227,0.12);--tag-lx-text:#0984e3; + --tag-qzv2-bg:rgba(0,206,201,0.12);--tag-qzv2-text:#00cec9; + --tag-musicfree-bg:rgba(0,184,148,0.12);--tag-musicfree-text:#00b894; + --tag-unknown-bg:rgba(100,100,120,0.12);--tag-unknown-text:#646478; + --log-bg:rgba(5,20,35,0.95);--log-text:#d0e8ff;--log-muted:#5080a0; +} + +[data-theme="forest"]{ + --bg-primary:rgba(235,248,235,0.75);--bg-secondary:rgba(245,255,245,0.85); + --bg-tertiary:rgba(225,245,225,0.9);--bg-card:rgba(245,255,245,0.8); + --bg-hover:rgba(34,139,34,0.06);--bg-input:rgba(250,255,250,0.9); + --text-primary:#1a3d1a;--text-secondary:#2d5d2d;--text-muted:#608060; + --border-color:rgba(34,139,34,0.1);--border-strong:rgba(34,139,34,0.18); + --accent:#00b894;--accent-light:#55efc4;--accent-bg:rgba(0,184,148,0.1); + --success:#2ecc71;--success-bg:rgba(46,204,113,0.1); + --warning:#fdcb6e;--warning-bg:rgba(253,203,110,0.12); + --danger:#e17055;--danger-bg:rgba(225,112,85,0.1); + --shadow-sm:0 2px 8px rgba(34,139,34,0.1);--shadow-md:0 4px 20px rgba(34,139,34,0.12); + --shadow-lg:0 8px 40px rgba(34,139,34,0.18); + --glass-blur:blur(20px);--text-on-accent:#fff; + --tag-lx-bg:rgba(0,184,148,0.12);--tag-lx-text:#00b894; + --tag-qzv2-bg:rgba(46,204,113,0.12);--tag-qzv2-text:#2ecc71; + --tag-musicfree-bg:rgba(155,89,182,0.12);--tag-musicfree-text:#9b59b6; + --tag-unknown-bg:rgba(100,100,120,0.12);--tag-unknown-text:#646478; + --log-bg:rgba(15,30,15,0.95);--log-text:#d0f0d0;--log-muted:#508050; +} + +[data-theme="midnight"]{ + --bg-primary:rgba(25,20,40,0.8);--bg-secondary:rgba(35,28,55,0.85); + --bg-tertiary:rgba(45,35,65,0.9);--bg-card:rgba(38,30,58,0.75); + --bg-hover:rgba(255,255,255,0.06);--bg-input:rgba(25,20,40,0.9); + --text-primary:#e0d8f0;--text-secondary:#b0a0c8;--text-muted:#7a6a90; + --border-color:rgba(180,160,220,0.08);--border-strong:rgba(180,160,220,0.15); + --accent:#bf6cff;--accent-light:#d4a3ff;--accent-bg:rgba(191,108,255,0.15); + --success:#00d2a0;--success-bg:rgba(0,210,160,0.12); + --warning:#ffce5c;--warning-bg:rgba(255,206,92,0.12); + --danger:#ff6b8a;--danger-bg:rgba(255,107,138,0.12); + --shadow-sm:0 2px 8px rgba(0,0,0,0.4);--shadow-md:0 4px 20px rgba(0,0,0,0.5); + --shadow-lg:0 8px 40px rgba(0,0,0,0.6); + --glass-blur:blur(20px);--text-on-accent:#fff; + --tag-lx-bg:rgba(191,108,255,0.18);--tag-lx-text:#bf6cff; + --tag-qzv2-bg:rgba(0,210,160,0.18);--tag-qzv2-text:#00d2a0; + --tag-musicfree-bg:rgba(255,107,138,0.18);--tag-musicfree-text:#ff6b8a; + --tag-unknown-bg:rgba(150,150,170,0.18);--tag-unknown-text:#a0a0b8; + --log-bg:rgba(10,8,18,0.95);--log-text:#e0d8f0;--log-muted:#6a5a80; +} + +[data-theme="sunset"]{ + --bg-primary:rgba(255,242,230,0.75);--bg-secondary:rgba(255,248,240,0.85); + --bg-tertiary:rgba(255,235,218,0.9);--bg-card:rgba(255,248,240,0.8); + --bg-hover:rgba(255,140,80,0.06);--bg-input:rgba(255,252,248,0.9); + --text-primary:#5a2a0a;--text-secondary:#8a4a1a;--text-muted:#b08060; + --border-color:rgba(220,100,30,0.1);--border-strong:rgba(220,100,30,0.18); + --accent:#ff7f50;--accent-light:#ffb088;--accent-bg:rgba(255,127,80,0.1); + --success:#2ecc71;--success-bg:rgba(46,204,113,0.1); + --warning:#f39c12;--warning-bg:rgba(243,156,18,0.12); + --danger:#e74c3c;--danger-bg:rgba(231,76,60,0.1); + --shadow-sm:0 2px 8px rgba(220,100,30,0.1);--shadow-md:0 4px 20px rgba(220,100,30,0.12); + --shadow-lg:0 8px 40px rgba(220,100,30,0.18); + --glass-blur:blur(20px);--text-on-accent:#fff; + --tag-lx-bg:rgba(255,127,80,0.12);--tag-lx-text:#ff7f50; + --tag-qzv2-bg:rgba(46,204,113,0.12);--tag-qzv2-text:#2ecc71; + --tag-musicfree-bg:rgba(155,89,182,0.12);--tag-musicfree-text:#9b59b6; + --tag-unknown-bg:rgba(100,100,120,0.12);--tag-unknown-text:#646478; + --log-bg:rgba(30,15,5,0.95);--log-text:#f0d8c0;--log-muted:#9a7050; +} + +body{font-family:var(--font-sans);color:var(--text-primary);min-height:100vh;background-image:var(--bg-image);background-size:cover;background-position:center;background-attachment:fixed;background-repeat:no-repeat;transition:color var(--transition);position:relative} +body::before{content:'';position:fixed;inset:0;background:var(--bg-primary);z-index:-1;transition:background var(--transition)} +::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:4px} +::-webkit-scrollbar-thumb:hover{background:var(--accent)} + +.container{max-width:1200px;margin:0 auto;padding:24px 20px 60px} +.navbar{position:sticky;top:0;z-index:100;background:var(--bg-secondary);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border-bottom:1px solid var(--border-color);padding:0 20px;transition:background var(--transition),border-color var(--transition)} +.navbar-inner{max-width:1200px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:64px} +.navbar-brand{display:flex;align-items:center;gap:12px;font-size:18px;font-weight:700;color:var(--text-primary);text-decoration:none} +.navbar-brand .brand-icon{width:36px;height:36px;background:linear-gradient(135deg,var(--accent),var(--accent-light));border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text-on-accent);flex-shrink:0} +.navbar-brand .brand-icon svg{width:20px;height:20px} +.navbar-brand .brand-subtitle{font-size:12px;font-weight:400;color:var(--text-muted);margin-top:2px} +.navbar-actions{display:flex;align-items:center;gap:8px} +.theme-selector{position:relative} +.theme-dropdown-btn{display:flex;align-items:center;gap:6px;padding:8px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:var(--radius-sm);color:var(--text-secondary);cursor:pointer;font-size:13px;font-family:var(--font-sans);transition:all var(--transition)} +.theme-dropdown-btn:hover{border-color:var(--accent);color:var(--accent)} +.theme-dropdown-btn svg{width:16px;height:16px} +.theme-dropdown{position:absolute;top:calc(100% + 8px);right:0;background:var(--bg-secondary);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);border-radius:var(--radius-md);box-shadow:var(--shadow-lg);padding:8px;min-width:180px;display:none;z-index:200} +.theme-dropdown.show{display:block} +.theme-option{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--radius-sm);cursor:pointer;color:var(--text-secondary);font-size:14px;transition:all 0.2s} +.theme-option:hover{background:var(--bg-hover);color:var(--text-primary)} +.theme-option.active{background:var(--accent-bg);color:var(--accent)} +.theme-option .theme-swatch{width:20px;height:20px;border-radius:50%;border:2px solid var(--border-strong);flex-shrink:0} +.theme-option .theme-check{margin-left:auto;width:16px;height:16px;opacity:0} +.theme-option.active .theme-check{opacity:1} + +.hero{text-align:center;padding:48px 20px 32px} +.hero h1{font-size:36px;font-weight:800;background:linear-gradient(135deg,var(--accent),var(--accent-light));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:12px;letter-spacing:-0.5px} +.hero p{font-size:16px;color:var(--text-secondary);max-width:600px;margin:0 auto;line-height:1.6} +.hero-badges{display:flex;justify-content:center;gap:12px;margin-top:20px;flex-wrap:wrap} +.hero-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:var(--bg-card);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);border-radius:100px;font-size:13px;color:var(--text-secondary)} +.hero-badge svg{width:14px;height:14px} + +.card{background:var(--bg-card);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);border-radius:var(--radius-lg);padding:24px;box-shadow:var(--shadow-md);transition:all var(--transition);margin-bottom:20px} +.card-header{display:flex;align-items:center;gap:10px;margin-bottom:16px} +.card-header svg{width:20px;height:20px;color:var(--accent)} +.card-header h2{font-size:17px;font-weight:600;color:var(--text-primary)} + +/* 平台展示 */ +.platforms-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px} +.platform-info-card{background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:var(--radius-md);padding:16px;transition:all var(--transition)} +.platform-info-card:hover{border-color:var(--accent);box-shadow:var(--shadow-sm)} +.platform-info-header{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.platform-info-icon{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0} +.platform-info-icon svg{width:20px;height:20px} +.platform-info-icon.lx{background:var(--tag-lx-bg);color:var(--tag-lx-text)} +.platform-info-icon.qzv2{background:var(--tag-qzv2-bg);color:var(--tag-qzv2-text)} +.platform-info-icon.musicfree{background:var(--tag-musicfree-bg);color:var(--tag-musicfree-text)} +.platform-info-name{font-size:15px;font-weight:600;color:var(--text-primary)} +.platform-info-format{font-size:12px;color:var(--text-muted)} +.platform-info-section{margin-bottom:8px} +.platform-info-label{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px} +.platform-info-tags{display:flex;flex-wrap:wrap;gap:4px} +.platform-info-tag{padding:2px 8px;border-radius:4px;font-size:11px;background:var(--bg-card);color:var(--text-secondary);border:1px solid var(--border-color)} + +.input-tabs{display:flex;gap:4px;margin-bottom:16px;background:var(--bg-tertiary);border-radius:var(--radius-sm);padding:4px} +.input-tab{flex:1;padding:10px 16px;border:none;background:transparent;color:var(--text-secondary);font-size:14px;font-family:var(--font-sans);border-radius:6px;cursor:pointer;transition:all 0.2s;display:flex;align-items:center;justify-content:center;gap:6px} +.input-tab svg{width:16px;height:16px} +.input-tab.active{background:var(--bg-card);color:var(--accent);box-shadow:var(--shadow-sm)} +.input-tab:hover:not(.active){color:var(--text-primary)} + +.url-input-wrap{position:relative} +.url-textarea{width:100%;min-height:140px;padding:16px;background:var(--bg-input);border:1px solid var(--border-color);border-radius:var(--radius-md);color:var(--text-primary);font-size:14px;font-family:var(--font-mono);line-height:1.6;resize:vertical;transition:border-color var(--transition)} +.url-textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)} +.url-textarea::placeholder{color:var(--text-muted)} +.url-count{position:absolute;bottom:12px;right:16px;font-size:12px;color:var(--text-muted);background:var(--bg-secondary);padding:2px 8px;border-radius:100px} + +.file-upload-area{border:2px dashed var(--border-strong);border-radius:var(--radius-md);padding:40px 20px;text-align:center;cursor:pointer;transition:all var(--transition);background:var(--bg-input)} +.file-upload-area:hover,.file-upload-area.dragover{border-color:var(--accent);background:var(--accent-bg)} +.file-upload-area svg{width:40px;height:40px;color:var(--text-muted);margin-bottom:12px} +.file-upload-area p{color:var(--text-secondary);font-size:14px} +.file-upload-area .file-types{font-size:12px;color:var(--text-muted);margin-top:6px} + +/* 上传进度 */ +.upload-progress{margin-top:12px;padding:12px 16px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border:1px solid var(--border-color)} +.upload-progress-header{display:flex;justify-content:space-between;margin-bottom:8px;font-size:13px;color:var(--text-secondary)} +.upload-progress-bar{height:6px;background:var(--bg-hover);border-radius:3px;overflow:hidden} +.upload-progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent-light));border-radius:3px;transition:width 0.3s ease;width:0%} + +.file-list{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px} +.file-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--accent-bg);border-radius:100px;font-size:13px;color:var(--accent)} +.file-chip svg{width:14px;height:14px} +.file-chip .remove-file{cursor:pointer;opacity:0.6;transition:opacity 0.2s} +.file-chip .remove-file:hover{opacity:1} + +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 24px;border:none;border-radius:var(--radius-sm);font-size:14px;font-family:var(--font-sans);font-weight:600;cursor:pointer;transition:all var(--transition);text-decoration:none;white-space:nowrap} +.btn svg{width:18px;height:18px} +.btn-primary{background:linear-gradient(135deg,var(--accent),var(--accent-light));color:var(--text-on-accent);box-shadow:var(--shadow-sm)} +.btn-primary:hover{transform:translateY(-1px);box-shadow:var(--shadow-md)} +.btn-primary:active{transform:translateY(0)} +.btn-primary:disabled{opacity:0.5;cursor:not-allowed;transform:none} +.btn-secondary{background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border-color)} +.btn-secondary:hover{border-color:var(--accent);color:var(--accent)} +.btn-ghost{background:transparent;color:var(--text-secondary);padding:8px 14px} +.btn-ghost:hover{background:var(--bg-hover);color:var(--text-primary)} + +.actions-bar{display:flex;justify-content:space-between;align-items:center;margin-top:16px;gap:12px;flex-wrap:wrap} +.actions-left{display:flex;gap:8px;align-items:center} +.actions-right{display:flex;gap:8px} + +.progress-bar{width:100%;height:4px;background:var(--bg-tertiary);border-radius:2px;overflow:hidden;margin-top:16px;display:none} +.progress-bar.show{display:block} +.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent-light));border-radius:2px;transition:width 0.3s ease;width:0%} + +/* === 日志面板 === */ +.log-panel-card{padding:0;overflow:hidden} +.log-panel-card .card-header{padding:16px 20px;margin-bottom:0;border-bottom:1px solid var(--border-color);background:var(--bg-tertiary)} +.log-pulse{width:8px;height:8px;border-radius:50%;background:var(--success);animation:pulse 1.5s infinite} +.log-pulse.idle{background:var(--text-muted);animation:none} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.3}} + +.log-panel{background:var(--log-bg);font-family:var(--font-mono);font-size:13px;line-height:1.6;color:var(--log-text);padding:16px;max-height:400px;overflow-y:auto;} +.log-entry{display:flex;gap:8px;padding:2px 0;animation:logIn 0.2s ease} +.log-time{color:var(--log-muted);flex-shrink:0;font-size:12px;min-width:65px} +.log-level{flex-shrink:0;font-weight:600;min-width:55px;font-size:12px} +.log-level.info{color:#74b9ff} +.log-level.success{color:var(--success)} +.log-level.warn{color:var(--warning)} +.log-level.error{color:var(--danger)} +.log-message{flex:1;word-break:break-word} +.log-entry.plugin-header{border-top:1px solid rgba(255,255,255,0.05);margin-top:6px;padding-top:6px} +.log-entry.plugin-header .log-message{font-weight:600;color:var(--accent-light)} +@keyframes logIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:translateX(0)}} + +/* 结果卡片 */ +.results-section{margin-top:32px} +.results-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px} +.results-header h2{font-size:20px;font-weight:700;color:var(--text-primary);display:flex;align-items:center;gap:8px} +.results-header h2 svg{width:22px;height:22px;color:var(--accent)} +.results-actions{display:flex;gap:8px} +.results-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:16px} + +.result-card{background:var(--bg-card);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-md);transition:all var(--transition);position:relative;overflow:hidden;animation:cardIn 0.3s ease} +@keyframes cardIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}} +.result-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg);border-color:var(--border-strong)} +.result-card.status-passed{border-left:4px solid var(--success)} +.result-card.status-warning{border-left:4px solid var(--warning)} +.result-card.status-failed{border-left:4px solid var(--danger)} +.result-card.status-pending{border-left:4px solid var(--text-muted)} + +.result-card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px;gap:10px} +.result-card-title{flex:1;min-width:0} +.result-name{font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.result-url{font-size:12px;color:var(--text-muted);font-family:var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +.score-badge{width:56px;height:56px;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;flex-shrink:0;font-weight:700} +.score-badge .score-num{font-size:20px;line-height:1} +.score-badge .score-grade{font-size:10px;opacity:0.8;margin-top:2px} +.score-badge.grade-S{background:var(--success-bg);color:var(--success)} +.score-badge.grade-A{background:var(--success-bg);color:var(--success)} +.score-badge.grade-B{background:var(--warning-bg);color:var(--warning)} +.score-badge.grade-C{background:var(--warning-bg);color:var(--warning)} +.score-badge.grade-D{background:var(--danger-bg);color:var(--danger)} + +.platform-tags{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px} +.platform-tag{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:100px;font-size:12px;font-weight:500} +.platform-tag svg{width:12px;height:12px} +.platform-tag.tag-lx{background:var(--tag-lx-bg);color:var(--tag-lx-text)} +.platform-tag.tag-qzv2{background:var(--tag-qzv2-bg);color:var(--tag-qzv2-text)} +.platform-tag.tag-musicfree{background:var(--tag-musicfree-bg);color:var(--tag-musicfree-text)} +.platform-tag.tag-unknown{background:var(--tag-unknown-bg);color:var(--tag-unknown-text)} + +.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 16px;margin-bottom:14px} +.info-item{display:flex;flex-direction:column;gap:2px} +.info-label{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px} +.info-value{font-size:13px;color:var(--text-primary);font-weight:500} + +.latency-bar{display:flex;align-items:center;gap:8px;margin-bottom:6px} +.latency-bar-label{font-size:12px;color:var(--text-secondary);min-width:50px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.latency-bar-track{flex:1;height:6px;background:var(--bg-tertiary);border-radius:3px;overflow:hidden} +.latency-bar-fill{height:100%;border-radius:3px;transition:width 0.5s ease} +.latency-bar-fill.good{background:var(--success)} +.latency-bar-fill.ok{background:var(--warning)} +.latency-bar-fill.bad{background:var(--danger)} +.latency-bar-value{font-size:12px;font-weight:600;min-width:55px;text-align:right} +.latency-bar-value.good{color:var(--success)} +.latency-bar-value.ok{color:var(--warning)} +.latency-bar-value.bad{color:var(--danger)} + +.quality-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px} +.quality-tag{padding:3px 8px;border-radius:4px;font-size:11px;font-weight:500;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border-color)} +.quality-tag.declared{background:var(--success-bg);color:var(--success);border-color:transparent} + +.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:1000;display:none;align-items:center;justify-content:center;padding:20px} +.modal-overlay.show{display:flex} +.modal{background:var(--bg-secondary);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);max-width:800px;width:100%;max-height:85vh;overflow:hidden;display:flex;flex-direction:column} +.modal-header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;border-bottom:1px solid var(--border-color)} +.modal-header h3{font-size:18px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px} +.modal-header h3 svg{width:20px;height:20px;color:var(--accent)} +.modal-close{background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px;border-radius:6px;transition:all 0.2s} +.modal-close:hover{background:var(--bg-hover);color:var(--text-primary)} +.modal-close svg{width:20px;height:20px} +.modal-body{padding:24px;overflow-y:auto;flex:1} +.md-preview{background:var(--bg-input);border:1px solid var(--border-color);border-radius:var(--radius-md);padding:20px;font-size:14px;line-height:1.7;color:var(--text-primary);white-space:pre-wrap;font-family:var(--font-mono);max-height:500px;overflow-y:auto} +.poster-container{display:flex;justify-content:center;padding:20px 0} +#poster-canvas{max-width:100%;border-radius:var(--radius-md);box-shadow:var(--shadow-lg)} + +.toast-container{position:fixed;bottom:24px;right:24px;z-index:2000;display:flex;flex-direction:column;gap:8px} +.toast{display:flex;align-items:center;gap:10px;padding:12px 20px;background:var(--bg-secondary);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border:1px solid var(--border-color);border-radius:var(--radius-sm);box-shadow:var(--shadow-md);color:var(--text-primary);font-size:14px;animation:toastIn 0.3s ease;max-width:380px} +.toast svg{width:18px;height:18px;flex-shrink:0} +.toast.success{border-left:3px solid var(--success)}.toast.success svg{color:var(--success)} +.toast.error{border-left:3px solid var(--danger)}.toast.error svg{color:var(--danger)} +.toast.info{border-left:3px solid var(--accent)}.toast.info svg{color:var(--accent)} +@keyframes toastIn{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:translateX(0)}} + +.spinner{width:18px;height:18px;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin 0.8s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} + +.empty-state{text-align:center;padding:60px 20px;color:var(--text-muted)} +.empty-state svg{width:48px;height:48px;margin-bottom:16px;opacity:0.5} +.empty-state p{font-size:15px} + +.skeleton-card{background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius-lg);padding:20px;height:200px;position:relative;overflow:hidden} +.skeleton-card::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,var(--bg-hover),transparent);animation:shimmer 1.5s infinite} +@keyframes shimmer{to{transform:translateX(100%)}} + +@media(max-width:768px){ + .hero h1{font-size:26px}.hero p{font-size:14px} + .results-grid{grid-template-columns:1fr}.info-grid{grid-template-columns:1fr} + .navbar-brand .brand-subtitle{display:none} + .actions-bar{flex-direction:column;align-items:stretch} + .actions-left,.actions-right{justify-content:stretch} + .actions-left .btn,.actions-right .btn{flex:1;justify-content:center} + .container{padding:16px 12px 40px}.card{padding:16px} + .theme-dropdown-btn span{display:none} + .platforms-grid{grid-template-columns:1fr} +} + +.footer{text-align:center;padding:40px 20px 20px;color:var(--text-muted);font-size:13px} +.footer a{color:var(--accent);text-decoration:none} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..19ccaaf --- /dev/null +++ b/public/index.html @@ -0,0 +1,194 @@ + + + + + + Koneko 音源测试平台 + + + + + + + + + + +
+ +
+

Koneko 音源测试平台

+

支持 Lx Music、QZV2、MusicFree 三大平台插件的音质与延时批量测试。自动识别插件格式,实时日志输出,生成 Markdown 表格与精美测试海报。

+
+ 自动格式识别 + 批量测试 + 实时日志 + 海报生成 +
+
+ + + + + +
+
+ +

插件输入

+
+ +
+ + +
+ +
+
+ +
0 个链接
+
+
+ + + +
+
+ + +
+
+ +
+
+ +
+
+
+
+ + + + + + + +
+ +

输入插件链接或上传文件后点击「开始测试」

+
+ +
+ + + + + + + + + +
+ + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..cf7f617 --- /dev/null +++ b/public/js/app.js @@ -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 = `${file.name}`; + 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 => ` +
+
+
+
+
${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).replace('

没有测试结果

'; + 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 = `${r.platformLabel || '未知'}`; + const scoreBadge = `
${score}
${grade}
`; + + 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 `
${label}
${t.success ? t.latency + 'ms' : '失败'}
`; + }).join(''); + } + + let qualityHtml = ''; + if (r.qualityTests?.length > 0) { + qualityHtml = r.qualityTests.map(q => `${q.quality}`).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 ? `${formatSize(dlInfo.size||0)} · ${dlInfo.latency||0}ms` : `${dlInfo.error || '失败'}`; + + return `
+
+
+
${name}
+
${r.url || r.filename || ''}
+
${scoreBadge} +
+
${platformTag}${r.metadata?.sources?.length ? r.metadata.sources.map(s => `${s.toUpperCase()}`).join('') : ''}
+
+
版本${version}
+
作者${author}
+
下载${dlStatus}
+
成功率${r.summary?.details?.successRate || 'N/A'}
+
+ ${latencyHtml ? `
延时测试
${latencyHtml}
` : ''} + ${qualityHtml ? `
音质支持
${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 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 = `${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)} diff --git a/server.js b/server.js new file mode 100644 index 0000000..39c21a3 --- /dev/null +++ b/server.js @@ -0,0 +1,219 @@ +/** + * Koneko 音源测试平台 - 服务器 v1.1 + * 端口: 1255 + * 支持 SSE 实时日志推送 + */ + +const express = require('express'); +const path = require('path'); +const { testPlugin } = require('./lib/tester'); +const { ALL_PLATFORMS } = require('./lib/detector'); + +const app = express(); +const PORT = 1255; + +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); +app.use(express.static(path.join(__dirname, 'public'))); + +/** + * 获取所有支持的平台信息 + */ +app.get('/api/platforms', (req, res) => { + res.json({ success: true, platforms: ALL_PLATFORMS }); +}); + +/** + * SSE 流式测试接口 + * POST /api/test/stream + * body: { plugins: [{ url, code, filename }] } + * 返回: text/event-stream + */ +app.post('/api/test/stream', async (req, res) => { + try { + const { plugins } = req.body; + if (!plugins || !Array.isArray(plugins) || plugins.length === 0) { + return res.status(400).json({ error: '请提供至少一个插件链接或文件' }); + } + if (plugins.length > 20) { + return res.status(400).json({ error: '单次最多测试 20 个插件' }); + } + + // SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + 'Access-Control-Allow-Origin': '*' + }); + + const send = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + send('start', { total: plugins.length, time: new Date().toISOString() }); + + const concurrencyLimit = 3; + let completed = 0; + + for (let i = 0; i < plugins.length; i += concurrencyLimit) { + const batch = plugins.slice(i, i + concurrencyLimit); + const batchResults = await Promise.all( + batch.map(async (p, batchIdx) => { + const globalIdx = i + batchIdx; + send('plugin_start', { index: globalIdx, url: p.url || '', filename: p.filename || '' }); + + const logger = (level, message, data) => { + send('log', { + index: globalIdx, + level, // info, success, warn, error + message, + data: data || null, + time: new Date().toISOString() + }); + }; + + try { + const result = await testPlugin(p.url || '', p.code || null, p.filename || '', logger); + send('plugin_done', { index: globalIdx, result }); + return result; + } catch (err) { + const errorResult = { + url: p.url || p.filename, filename: p.filename || '', + platform: 'unknown', platformLabel: '未知格式', + metadata: {}, downloadInfo: { success: false, error: err.message }, + latencyTests: [], qualityTests: [], overallStatus: 'failed', + summary: { score: 0, details: { error: err.message } }, + testedAt: new Date().toISOString() + }; + send('log', { index: globalIdx, level: 'error', message: `测试异常: ${err.message}`, time: new Date().toISOString() }); + send('plugin_done', { index: globalIdx, result: errorResult }); + return errorResult; + } + }) + ); + + completed += batchResults.length; + send('progress', { completed, total: plugins.length, percent: Math.round((completed / plugins.length) * 100) }); + } + + send('done', { total: plugins.length, time: new Date().toISOString() }); + res.end(); + } catch (err) { + console.error('测试出错:', err); + if (!res.headersSent) { + res.status(500).json({ error: '服务器内部错误: ' + err.message }); + } else { + res.end(); + } + } +}); + +/** + * 兼容旧接口 - 批量测试(非流式) + */ +app.post('/api/test', async (req, res) => { + try { + const { plugins } = req.body; + if (!plugins || !Array.isArray(plugins) || plugins.length === 0) { + return res.status(400).json({ error: '请提供至少一个插件链接或文件' }); + } + const results = []; + const concurrencyLimit = 5; + for (let i = 0; i < plugins.length; i += concurrencyLimit) { + const batch = plugins.slice(i, i + concurrencyLimit); + const batchResults = await Promise.all( + batch.map(p => testPlugin(p.url || '', p.code || null, p.filename || '').catch(err => ({ + url: p.url || p.filename, filename: p.filename || '', platform: 'unknown', + platformLabel: '未知格式', metadata: {}, downloadInfo: { success: false, error: err.message }, + latencyTests: [], qualityTests: [], overallStatus: 'failed', + summary: { score: 0, details: { error: err.message } }, testedAt: new Date().toISOString() + }))) + ); + results.push(...batchResults); + } + res.json({ success: true, count: results.length, results, testedAt: new Date().toISOString() }); + } catch (err) { + res.status(500).json({ error: '服务器内部错误: ' + err.message }); + } +}); + +/** + * 导出 Markdown 表格 + */ +app.post('/api/export/md', (req, res) => { + try { + const { results } = req.body; + if (!results || !Array.isArray(results)) return res.status(400).json({ error: '无效的测试结果' }); + res.json({ success: true, markdown: generateMarkdownTable(results) }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', service: 'Koneko 音源测试平台', version: '1.1.0', time: new Date().toISOString() }); +}); + +function generateMarkdownTable(results) { + const lines = []; + lines.push('# Koneko 音源测试平台 - 测试报告'); + lines.push(''); + lines.push(`> 测试时间: ${new Date().toLocaleString('zh-CN')}`); + lines.push(`> 测试数量: ${results.length}`); + lines.push(''); + lines.push('## 测试概览'); + lines.push(''); + lines.push('| # | 插件名称 | 平台 | 版本 | 音质 | 延时(ms) | 评分 | 等级 | 状态 |'); + lines.push('|---|---------|------|------|------|---------|------|------|------|'); + results.forEach((r, i) => { + const name = r.metadata?.name || r.filename || r.url || '未知'; + const platform = r.platform === 'unknown' ? '未知' : r.platform.toUpperCase(); + const version = r.metadata?.version || '-'; + const quality = (r.qualityTests?.map(q => q.quality).join(', ')) || '-'; + const avgLatency = r.summary?.details?.avgLatency ?? '-'; + const score = r.summary?.score ?? 0; + const grade = r.summary?.details?.grade ?? '-'; + const status = r.overallStatus === 'passed' ? '通过' : (r.overallStatus === 'warning' ? '警告' : '失败'); + lines.push(`| ${i + 1} | ${name} | ${platform} | ${version} | ${quality} | ${avgLatency} | ${score} | ${grade} | ${status} |`); + }); + lines.push(''); + lines.push('## 详细信息'); + lines.push(''); + results.forEach((r, i) => { + const name = r.metadata?.name || r.filename || r.url || '未知'; + lines.push(`### ${i + 1}. ${name}`); + lines.push(''); + lines.push(`- **插件链接**: ${r.url || '文件上传'}`); + lines.push(`- **平台格式**: ${r.platformLabel}`); + lines.push(`- **版本**: ${r.metadata?.version || '未指定'}`); + lines.push(`- **作者**: ${r.metadata?.author || '未知'}`); + lines.push(`- **音质支持**: ${r.qualityTests?.map(q => `${q.quality}${q.declared ? '' : '(默认)'}`).join(', ') || '无'}`); + lines.push(`- **功能列表**: ${r.metadata?.features?.join(', ') || '无'}`); + lines.push(`- **评分**: ${r.summary?.score || 0}/100 (${r.summary?.details?.grade || 'D'})`); + lines.push(''); + if (r.latencyTests?.length > 0) { + lines.push('#### 延时测试'); + lines.push(''); + lines.push('| 端点 | 状态码 | 延时(ms) | 状态 |'); + lines.push('|------|--------|---------|------|'); + for (const t of r.latencyTests) { + const status = t.success ? '成功' : `失败(${t.error || ''})`; + const displayUrl = t.url.length > 60 ? t.url.substring(0, 57) + '...' : t.url; + lines.push(`| ${displayUrl} | ${t.status} | ${t.latency} | ${status} |`); + } + lines.push(''); + } + lines.push('---'); + lines.push(''); + }); + return lines.join('\n'); +} + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Koneko 音源测试平台 v1.1 已启动`); + console.log(`服务地址: http://0.0.0.0:${PORT}`); + console.log(`测试时间: ${new Date().toLocaleString('zh-CN')}`); +});