From 53e2294d988f54cb1971236f35dfbebe3c683f5d Mon Sep 17 00:00:00 2001 From: lilixu3 Date: Sat, 13 Jun 2026 13:44:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BC=B9=E5=B9=95=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=83=AD=E5=8A=9B=E5=9B=BE=E3=80=81=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E4=B8=8E=E7=BB=9F=E8=AE=A1=E5=8D=A1=E7=89=87=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 增强弹幕测试结果页调用链路体验 变动内容: - 在弹幕测试结果页增加调用链路展示,覆盖自动匹配、手动搜索、番剧详情和弹幕拉取过程。 - 将统计卡文案调整为链路耗时,并按当前请求链路汇总耗时。 - 优化热力图滑动提示、边缘防溢出、筛选标签数量显示和横向滚动体验。 - 修复切换番剧/剧集时旧调用链路追加、旧请求返回覆盖新结果、失败和空结果缺少链路信息的问题。 * 修复弹幕测试调用链路串线 变动内容: - 为自动匹配、手动搜索、番剧详情和弹幕拉取增加独立请求令牌,避免旧响应覆盖新结果。 - 传递并克隆当前调用链路,修正手动失败和空结果的返回目标。 - 清理弹幕测试静态 inline 样式,补充热力图、筛选、跳集和移动端样式。 - 收敛方法标记和热力图类名,复用弹幕类型统计并增强长文本展示。 * 移除弹幕测试详情页调用链路显示 * 弹幕测试统计改为彩色占比 --- danmu_api/ui/css/components.css.js | 177 +++++++++- danmu_api/ui/js/apitest.js | 525 +++++++++++++++++++++++++---- 2 files changed, 639 insertions(+), 63 deletions(-) diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js index a7be380..cf0356e 100644 --- a/danmu_api/ui/css/components.css.js +++ b/danmu_api/ui/css/components.css.js @@ -1269,6 +1269,16 @@ export const componentsCssContent = /* css */ ` color: #999; } +.danmu-result-error { + margin-bottom: 15px; + background: #fff8f8; + border: 1px solid #ffcdd2; + color: #c62828; + border-radius: 10px; + padding: 12px 15px; + font-size: 13px; +} + /* 弹幕热力图 */ .danmu-heatmap-container { margin-bottom: 15px; @@ -1277,11 +1287,24 @@ export const componentsCssContent = /* css */ ` padding: 15px; } -.danmu-heatmap-container h3 { +.danmu-section-title { margin: 0 0 10px; font-size: 15px; } +.danmu-section-title-spaced { + margin-top: 15px; +} + +.danmu-empty-text { + color: #999; +} + +.danmu-empty-list { + text-align: center; + padding: 20px; +} + .heatmap-bars { display: flex; align-items: flex-end; @@ -1290,14 +1313,84 @@ export const componentsCssContent = /* css */ ` padding: 0 2px; } +.danmu-heatmap-interactive { + position: relative; + padding-top: 34px; + touch-action: pan-y; + user-select: none; +} + +.danmu-heatmap-tooltip { + position: absolute; + top: 0; + left: 0; + transform: translateX(-50%) translateY(-4px); + display: flex; + align-items: baseline; + gap: 4px; + padding: 5px 9px; + border-radius: 999px; + background: rgba(51, 51, 51, 0.92); + color: white; + font-size: 12px; + line-height: 1.2; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease, transform 0.12s ease; + z-index: 2; +} + +.danmu-heatmap-tooltip.active { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.danmu-heatmap-tooltip strong { + font-size: 15px; +} + +.danmu-heatmap-tooltip em { + color: rgba(255,255,255,0.72); + font-style: normal; + margin-left: 3px; +} + +.danmu-heatmap-indicator { + position: absolute; + top: 34px; + bottom: 0; + width: 2px; + transform: translateX(-50%); + border-radius: 999px; + background: rgba(102, 126, 234, 0.88); + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.14); + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + z-index: 1; +} + +.danmu-heatmap-indicator.active { + opacity: 1; +} + .heatmap-bar { flex: 1; min-width: 3px; + height: var(--heatmap-height, 2%); + background: var(--heatmap-color, #667eea); border-radius: 2px 2px 0 0; - transition: opacity 0.2s; + transition: opacity 0.2s, transform 0.12s ease, box-shadow 0.12s ease; cursor: pointer; } +.heatmap-bar.active { + opacity: 0.95; + transform: translateY(-2px); + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.24); +} + .heatmap-bar:hover { opacity: 0.8; } @@ -1313,12 +1406,27 @@ export const componentsCssContent = /* css */ ` /* 弹幕过滤标签 */ .danmu-filter-tabs { display: flex; + flex-wrap: nowrap; gap: 8px; margin-bottom: 12px; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 2px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.danmu-filter-tabs::-webkit-scrollbar { + display: none; } .danmu-filter-tab { - padding: 6px 16px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + padding: 6px 12px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 15px; @@ -1338,6 +1446,25 @@ export const componentsCssContent = /* css */ ` border-color: #667eea; } +.danmu-filter-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: rgba(0,0,0,0.08); + color: inherit; + font-size: 11px; + font-weight: 600; + line-height: 1; +} + +.danmu-filter-tab.active .danmu-filter-count { + background: rgba(255,255,255,0.22); +} + /* 弹幕列表 */ .danmu-list-area { background: #f8f9fa; @@ -1390,6 +1517,7 @@ export const componentsCssContent = /* css */ ` border-radius: 50%; flex-shrink: 0; border: 1px solid rgba(0,0,0,0.1); + background: var(--danmu-color-dot, #fff); } .danmu-mode-tag { @@ -1417,6 +1545,7 @@ export const componentsCssContent = /* css */ ` .danmu-text { flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1424,9 +1553,45 @@ export const componentsCssContent = /* css */ ` } .danmu-load-more { + display: none; + width: 100%; margin-top: 10px; } +.jump-to-episode { + margin-top: 15px; + margin-bottom: 15px; + padding: 10px; + background: #f8f9fa; + border-radius: 12px; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.jump-episode-input { + padding: 8px; + width: 90px; + border: 1px solid #ccc; + border-radius: 8px; +} + +.jump-episode-btn { + margin-left: 5px; + border-radius: 8px; +} + +.jump-episode-total { + margin-left: 5px; + color: #666; + font-size: 14px; +} + +.episode-item.episode-item-highlight { + background: #fff3cd; +} + /* 返回按钮 */ .btn-back { padding: 8px 16px; @@ -1502,6 +1667,12 @@ export const componentsCssContent = /* css */ ` .heatmap-bars { height: 70px; + gap: 1px; + padding: 0 1px; + } + + .heatmap-bar { + min-width: 2px; } } diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js index 3a9ac04..448755b 100644 --- a/danmu_api/ui/js/apitest.js +++ b/danmu_api/ui/js/apitest.js @@ -67,7 +67,16 @@ let danmuTestState = { pageSize: 100, currentEpisodeId: null, currentTitle: '', - currentDuration: 0 + currentDuration: 0, + currentCallTrace: null, + currentManualSearchCallTraceBase: null, + currentManualEpisodeCallTraceBase: null, + nextDanmuRequestId: 0, + activeDanmuRequestId: 0, + nextDanmuFlowRequestId: 0, + activeDanmuFlowRequestId: 0, + nextManualBangumiRequestId: 0, + activeManualBangumiRequestId: 0 }; // 初始化接口调试界面 @@ -565,6 +574,152 @@ function backBtnHtml(text, onclick) { return ''; } +function inlineJsString(value) { + return escapeHtml(JSON.stringify(String(value || ''))); +} + +function createDanmuCallTrace(mode, inputText) { + return { + mode: mode || 'manual', + inputText: inputText || '', + steps: [], + startedAt: performance.now() + }; +} + +function cloneDanmuCallTrace(trace) { + if (!trace) return null; + return { + mode: trace.mode, + inputText: trace.inputText, + startedAt: performance.now(), + steps: (trace.steps || []).map(step => Object.assign({}, step)) + }; +} + +function createDanmuCallStep(step) { + return Object.assign({ + name: '', + method: 'GET', + url: '', + params: '', + result: '', + elapsed: 0, + status: 'success' + }, step); +} + +function addDanmuCallStep(trace, step) { + if (!trace) return; + trace.steps.push(createDanmuCallStep(step)); +} + +function finishDanmuCallStep(trace, startedAt, step) { + addDanmuCallStep(trace, Object.assign({}, step, { + elapsed: performance.now() - startedAt + })); +} + +function finishDanmuCallFailure(trace, startedAt, step, error) { + const message = error && error.message ? error.message : String(error || '未知错误'); + finishDanmuCallStep(trace, startedAt, Object.assign({ + status: 'error', + result: '失败:' + message + }, step)); +} + +function finishDanmuCallEmpty(trace, startedAt, step, message) { + finishDanmuCallStep(trace, startedAt, Object.assign({ + status: 'empty', + result: message || '无结果' + }, step)); +} + +function safeDanmuApiPath(path) { + return String(path || '').replace(new RegExp('^/[^/]+/api/'), '//api/'); +} + +function renderDanmuCallTrace(trace) { + return ''; +} + +function getDanmuCallTraceTotalMs(trace) { + if (!trace || !Array.isArray(trace.steps) || trace.steps.length === 0) return 0; + return trace.steps.reduce((sum, step) => sum + (Number(step.elapsed) || 0), 0); +} + +function renderDanmuCallFailure(trace, title, message, source) { + let html = '
'; + if (source === 'manual-episode') { + html += backBtnHtml('返回列表', 'backToEpisodeList()'); + } else if (source === 'manual-anime') { + html += backBtnHtml('返回搜索结果', 'backToManualSearch()'); + } + html += '
'; + html += '
' + escapeHtml(title || '调用失败') + ':' + escapeHtml(message || '请求失败') + '
'; + html += renderDanmuCallTrace(trace); + return html; +} + +function showDanmuCallTraceFailure(trace, title, message, source) { + const resultArea = document.getElementById('danmu-result-area'); + if (!resultArea) return; + resultArea.innerHTML = renderDanmuCallFailure(trace, title, message, source); + resultArea.style.display = 'block'; +} + +function getDanmuCallTraceTotalSeconds(trace, fallbackSeconds) { + const totalMs = getDanmuCallTraceTotalMs(trace); + if (totalMs > 0) return (totalMs / 1000).toFixed(2); + return fallbackSeconds || '0.00'; +} + +function formatCallElapsed(ms) { + if (!Number.isFinite(ms)) return '--'; + return ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : Math.round(ms) + 'ms'; +} + +function startDanmuFlowRequest() { + const requestId = ++danmuTestState.nextDanmuFlowRequestId; + danmuTestState.activeDanmuFlowRequestId = requestId; + danmuTestState.activeManualBangumiRequestId = 0; + invalidateActiveDanmuRequest(); + return requestId; +} + +function isCurrentDanmuFlowRequest(requestId) { + return danmuTestState.activeDanmuFlowRequestId === requestId; +} + +function startManualBangumiRequest() { + const requestId = ++danmuTestState.nextManualBangumiRequestId; + danmuTestState.activeManualBangumiRequestId = requestId; + danmuTestState.activeDanmuFlowRequestId = 0; + invalidateActiveDanmuRequest(); + return requestId; +} + +function isCurrentManualBangumiRequest(requestId) { + return danmuTestState.activeManualBangumiRequestId === requestId; +} + +function invalidateActiveDanmuRequest() { + danmuTestState.activeDanmuRequestId = 0; +} + +function setDanmuFlowButtonLoading(btn, requestId, loading) { + if (!btn) return; + if (loading) { + btn._danmuFlowRequestId = requestId; + setBtnLoading(btn, true); + return; + } + if (btn._danmuFlowRequestId === requestId) { + setBtnLoading(btn, false); + btn._danmuFlowRequestId = 0; + } +} + // ===================== // 自动匹配测试 // ===================== @@ -572,12 +727,19 @@ async function autoMatchTest() { const fileName = document.getElementById('auto-match-filename').value.trim(); if (!fileName) { customAlert('请输入文件名'); return; } + const flowRequestId = startDanmuFlowRequest(); const btn = document.getElementById('auto-match-btn'); - setBtnLoading(btn, true); + setDanmuFlowButtonLoading(btn, flowRequestId, true); document.getElementById('danmu-result-area').style.display = 'none'; addLog('自动匹配测试: ' + fileName, 'info'); + const trace = createDanmuCallTrace('auto', fileName); + danmuTestState.currentCallTrace = trace; + danmuTestState.currentManualSearchCallTraceBase = null; + danmuTestState.currentManualEpisodeCallTraceBase = null; + let matchStartedAt = 0; try { + matchStartedAt = performance.now(); const resp = await fetch(buildApiUrl('/api/v2/match'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -585,23 +747,46 @@ async function autoMatchTest() { }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); + if (!isCurrentDanmuFlowRequest(flowRequestId)) return; if (data.isMatched && data.matches && data.matches.length > 0) { const best = data.matches[0]; const title = (best.animeTitle || '') + ' ' + (best.episodeTitle || ''); + finishDanmuCallStep(trace, matchStartedAt, { + name: '匹配', + method: 'POST', + url: '/api/v2/match', + params: '文件名:' + fileName, + result: '命中 ' + data.matches.length + ' 个结果,episodeId: ' + best.episodeId + }); addLog('自动匹配命中: ' + title + ' (共' + data.matches.length + '个结果,取第1个)', 'success'); - setBtnLoading(btn, false); - fetchDanmuForTest(best.episodeId, title, 'auto'); + setDanmuFlowButtonLoading(btn, flowRequestId, false); + fetchDanmuForTest(best.episodeId, title, 'auto', trace); return; } else { + finishDanmuCallEmpty(trace, matchStartedAt, { + name: '匹配', + method: 'POST', + url: '/api/v2/match', + params: '文件名:' + fileName + }, '未匹配到任何结果'); customAlert('未匹配到任何结果'); addLog('自动匹配无结果', 'warn'); + showDanmuCallTraceFailure(trace, '自动匹配无结果', '未匹配到任何结果', 'auto'); } } catch (e) { + if (!isCurrentDanmuFlowRequest(flowRequestId)) return; + finishDanmuCallFailure(trace, matchStartedAt || performance.now(), { + name: '匹配', + method: 'POST', + url: '/api/v2/match', + params: '文件名:' + fileName + }, e); customAlert('匹配失败: ' + e.message); addLog('自动匹配失败: ' + e.message, 'error'); + showDanmuCallTraceFailure(trace, '自动匹配失败', e.message, 'auto'); } finally { - setBtnLoading(btn, false); + setDanmuFlowButtonLoading(btn, flowRequestId, false); } } @@ -612,39 +797,74 @@ async function manualSearchAnime() { const keyword = document.getElementById('manual-search-keyword').value.trim(); if (!keyword) { customAlert('请输入搜索关键字'); return; } + const flowRequestId = startDanmuFlowRequest(); const btn = document.getElementById('manual-search-btn'); - setBtnLoading(btn, true); + setDanmuFlowButtonLoading(btn, flowRequestId, true); document.getElementById('manual-anime-list').style.display = 'none'; document.getElementById('manual-episode-list').style.display = 'none'; document.getElementById('danmu-result-area').style.display = 'none'; addLog('手动搜索: ' + keyword, 'info'); + const trace = createDanmuCallTrace('manual', keyword); + danmuTestState.currentCallTrace = trace; + danmuTestState.currentManualSearchCallTraceBase = null; + danmuTestState.currentManualEpisodeCallTraceBase = null; + let searchStartedAt = 0; + let searchUrl = ''; try { - const resp = await fetch(buildApiUrl('/api/v2/search/anime?keyword=' + encodeURIComponent(keyword))); + searchUrl = '/api/v2/search/anime?keyword=' + encodeURIComponent(keyword); + searchStartedAt = performance.now(); + const resp = await fetch(buildApiUrl(searchUrl)); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); + if (!isCurrentDanmuFlowRequest(flowRequestId)) return; if (data.success && data.animes && data.animes.length > 0) { + finishDanmuCallStep(trace, searchStartedAt, { + name: '搜索', + method: 'GET', + url: searchUrl, + params: '关键词:' + keyword, + result: '找到 ' + data.animes.length + ' 个动漫' + }); + danmuTestState.currentManualSearchCallTraceBase = cloneDanmuCallTrace(trace); displayManualAnimeList(data.animes); addLog('搜索到 ' + data.animes.length + ' 个动漫', 'success'); } else { + finishDanmuCallEmpty(trace, searchStartedAt, { + name: '搜索', + method: 'GET', + url: searchUrl, + params: '关键词:' + keyword + }, '未找到相关动漫'); customAlert('未找到相关动漫'); addLog('搜索无结果', 'warn'); + showDanmuCallTraceFailure(trace, '搜索无结果', '未找到相关动漫', 'manual-search'); } } catch (e) { + if (!isCurrentDanmuFlowRequest(flowRequestId)) return; + finishDanmuCallFailure(trace, searchStartedAt || performance.now(), { + name: '搜索', + method: 'GET', + url: searchUrl || '/api/v2/search/anime', + params: '关键词:' + keyword + }, e); customAlert('搜索失败: ' + e.message); addLog('搜索失败: ' + e.message, 'error'); + showDanmuCallTraceFailure(trace, '搜索失败', e.message, 'manual-search'); } finally { - setBtnLoading(btn, false); + setDanmuFlowButtonLoading(btn, flowRequestId, false); } } function displayManualAnimeList(animes) { const container = document.getElementById('manual-anime-list'); - let html = '

搜索结果

'; + let html = '

搜索结果

'; animes.forEach(anime => { - const img = anime.imageUrl || 'https://placehold.co/150x200?text=No+Image'; - html += '
'; + const animeId = Number.parseInt(anime.animeId, 10); + if (!Number.isFinite(animeId)) return; + const img = escapeHtml(anime.imageUrl || 'https://placehold.co/150x200?text=No+Image'); + html += '
'; html += '' + escapeHtml(anime.animeTitle) + ''; html += '

' + escapeHtml(anime.animeTitle) + ' - 共' + (anime.episodeCount || '?') + '集

'; html += '
'; @@ -655,26 +875,59 @@ function displayManualAnimeList(animes) { } async function manualGetBangumi(animeId) { + const detailRequestId = startManualBangumiRequest(); + const requestTrace = danmuTestState.currentManualSearchCallTraceBase + ? cloneDanmuCallTrace(danmuTestState.currentManualSearchCallTraceBase) + : createDanmuCallTrace('manual', String(animeId || '')); + danmuTestState.currentCallTrace = requestTrace; addLog('获取番剧详情: ' + animeId, 'info'); // 隐藏搜索结果,显示剧集列表区域 showDanmuView(['manual-episode-list'], ['manual-anime-list', 'danmu-result-area']); + let bangumiStartedAt = 0; + const bangumiUrl = '/api/v2/bangumi/' + animeId; try { - const resp = await fetch(buildApiUrl('/api/v2/bangumi/' + animeId)); + bangumiStartedAt = performance.now(); + const resp = await fetch(buildApiUrl(bangumiUrl)); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); + if (!isCurrentManualBangumiRequest(detailRequestId)) return; - if (data.success && data.bangumi && data.bangumi.episodes) { - displayManualEpisodeList(data.bangumi.animeTitle, data.bangumi.episodes); - addLog('获取到 ' + data.bangumi.episodes.length + ' 个剧集', 'success'); + const episodes = data && data.bangumi && Array.isArray(data.bangumi.episodes) ? data.bangumi.episodes : []; + if (data.success && data.bangumi && episodes.length > 0) { + finishDanmuCallStep(requestTrace, bangumiStartedAt, { + name: '番剧详情', + method: 'GET', + url: bangumiUrl, + params: 'animeId: ' + animeId, + result: '获取到 ' + episodes.length + ' 个剧集' + }); + danmuTestState.currentManualEpisodeCallTraceBase = cloneDanmuCallTrace(requestTrace); + displayManualEpisodeList(data.bangumi.animeTitle, episodes); + addLog('获取到 ' + episodes.length + ' 个剧集', 'success'); } else { + finishDanmuCallEmpty(requestTrace, bangumiStartedAt, { + name: '番剧详情', + method: 'GET', + url: bangumiUrl, + params: 'animeId: ' + animeId + }, '该动漫暂无剧集信息'); customAlert('该动漫暂无剧集信息'); + showDanmuCallTraceFailure(requestTrace, '番剧详情无剧集', '该动漫暂无剧集信息', 'manual-anime'); // 恢复搜索结果 showDanmuView(['manual-anime-list'], ['manual-episode-list']); } } catch (e) { + if (!isCurrentManualBangumiRequest(detailRequestId)) return; + finishDanmuCallFailure(requestTrace, bangumiStartedAt || performance.now(), { + name: '番剧详情', + method: 'GET', + url: bangumiUrl, + params: 'animeId: ' + animeId + }, e); customAlert('获取番剧详情失败: ' + e.message); addLog('获取番剧详情失败: ' + e.message, 'error'); + showDanmuCallTraceFailure(requestTrace, '获取番剧详情失败', e.message, 'manual-anime'); showDanmuView(['manual-anime-list'], ['manual-episode-list']); } } @@ -682,25 +935,28 @@ async function manualGetBangumi(animeId) { function displayManualEpisodeList(animeTitle, episodes) { const container = document.getElementById('manual-episode-list'); let html = backBtnHtml('返回搜索结果', 'backToManualSearch()'); - html += '

剧集列表

'; + html += '

剧集列表

'; html += '

' + escapeHtml(animeTitle) + '

'; // 添加跳转到指定集数的功能 html += \` -
+
跳转到第 - + 集 - - 共\${episodes.length}集 + + 共\${episodes.length}集
\`; html += '
'; episodes.forEach(ep => { - const title = escapeHtml(animeTitle) + ' 第' + ep.episodeNumber + '集'; - html += '
'; - html += '
第' + ep.episodeNumber + '集 - ' + escapeHtml(ep.episodeTitle || '无标题') + '
'; - html += ''; + const episodeId = Number.parseInt(ep.episodeId, 10); + const episodeNumber = Number.parseInt(ep.episodeNumber, 10); + if (!Number.isFinite(episodeId) || !Number.isFinite(episodeNumber)) return; + const title = String(animeTitle || '') + ' 第' + episodeNumber + '集'; + html += '
'; + html += '
第' + episodeNumber + '集 - ' + escapeHtml(ep.episodeTitle || '无标题') + '
'; + html += ''; html += '
'; }); html += '
'; @@ -735,20 +991,37 @@ function jumpToEpisode() { // 滚动到指定元素位置 episodeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); // 添加高亮效果以便识别 - episodeElement.style.backgroundColor = '#fff3cd'; // 黄色背景高亮 + episodeElement.classList.add('episode-item-highlight'); setTimeout(() => { - episodeElement.style.backgroundColor = ''; // 恢复原色 + episodeElement.classList.remove('episode-item-highlight'); }, 2000); } else { customAlert('找不到第' + episodeNumber + '集'); } } +function isCurrentDanmuRequest(requestId) { + return danmuTestState.activeDanmuRequestId === requestId; +} + // ===================== // 获取弹幕并展示结果 // ===================== -async function fetchDanmuForTest(episodeId, title, source) { +async function fetchDanmuForTest(episodeId, title, source, traceBase) { addLog('获取弹幕: ' + episodeId + ' (' + title + ')', 'info'); + const requestId = ++danmuTestState.nextDanmuRequestId; + danmuTestState.activeDanmuRequestId = requestId; + let requestTrace = null; + if (traceBase) { + requestTrace = cloneDanmuCallTrace(traceBase); + danmuTestState.currentCallTrace = requestTrace; + } else if (source === 'manual' && danmuTestState.currentManualEpisodeCallTraceBase) { + danmuTestState.currentCallTrace = cloneDanmuCallTrace(danmuTestState.currentManualEpisodeCallTraceBase); + requestTrace = danmuTestState.currentCallTrace; + } else { + requestTrace = cloneDanmuCallTrace(danmuTestState.currentCallTrace); + danmuTestState.currentCallTrace = requestTrace; + } danmuTestState.currentEpisodeId = episodeId; danmuTestState.currentTitle = title; danmuTestState.currentDuration = 0; @@ -767,22 +1040,39 @@ async function fetchDanmuForTest(episodeId, title, source) { const startTime = performance.now(); try { - const data = await fetch(buildApiUrl('/api/v2/comment/' + episodeId + "?format=json&duration=true")) + const commentUrl = '/api/v2/comment/' + episodeId + '?format=json&duration=true'; + const data = await fetch(buildApiUrl(commentUrl)) .then(resp => { if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.json(); }); + if (!isCurrentDanmuRequest(requestId)) return; const elapsed = ((performance.now() - startTime) / 1000).toFixed(2); const durationSeconds = Number((data && data.videoDuration) || 0); if (!data.comments || data.comments.length === 0) { + finishDanmuCallEmpty(requestTrace, startTime, { + name: '弹幕', + method: 'GET', + url: commentUrl, + params: 'episodeId: ' + episodeId + ',format=json,duration=true' + }, '该剧集暂无弹幕数据'); customAlert('该剧集暂无弹幕数据'); addLog('无弹幕数据', 'warn'); - resultArea.style.display = 'none'; - if (source === 'manual') backToEpisodeList(); + const failureSource = source === 'manual' ? 'manual-episode' : source; + resultArea.innerHTML = renderDanmuCallFailure(requestTrace, '无弹幕数据', '该剧集暂无弹幕数据', failureSource); + resultArea.style.display = 'block'; return; } + finishDanmuCallStep(requestTrace, startTime, { + name: '弹幕', + method: 'GET', + url: commentUrl, + params: 'episodeId: ' + episodeId + ',format=json,duration=true', + result: data.comments.length + ' 条弹幕' + }); + addLog('获取到 ' + data.comments.length + ' 条弹幕, 耗时 ' + elapsed + 's', 'success'); danmuTestState.allComments = data.comments; @@ -800,32 +1090,38 @@ async function fetchDanmuForTest(episodeId, title, source) { '' + '
'; + const filterCounts = getDanmuFilterCounts(data.comments); resultArea.innerHTML = toolbarHtml + '
' + - '

弹幕热力图

' + - '

弹幕列表

' + - '
' + - '' + - '' + - '' + - '' + - '
' + + renderDanmuCallTrace(requestTrace) + + '

弹幕热力图

' + + '

弹幕列表

' + + renderDanmuFilterTabs(filterCounts) + '
' + - '' + + '' + '
'; applyDanmuFilter(); - renderDanmuStats(data, elapsed, title, durationSeconds); + renderDanmuStats(data, elapsed, title, durationSeconds, filterCounts); renderDanmuHeatmap(data.comments, durationSeconds); renderDanmuList(); setTimeout(() => resultArea.scrollIntoView({ behavior: 'smooth', block: 'start' }), 10); } catch (e) { + if (!isCurrentDanmuRequest(requestId)) return; + const commentUrl = '/api/v2/comment/' + episodeId + '?format=json&duration=true'; + finishDanmuCallFailure(requestTrace, startTime, { + name: '弹幕', + method: 'GET', + url: commentUrl, + params: 'episodeId: ' + episodeId + ',format=json,duration=true' + }, e); customAlert('获取弹幕失败: ' + e.message); addLog('获取弹幕失败: ' + e.message, 'error'); - resultArea.style.display = 'none'; - if (source === 'manual') backToEpisodeList(); + const failureSource = source === 'manual' ? 'manual-episode' : source; + resultArea.innerHTML = renderDanmuCallFailure(requestTrace, '获取弹幕失败', e.message, failureSource); + resultArea.style.display = 'block'; } } @@ -874,7 +1170,7 @@ async function exportDanmu(format) { // ===================== // 弹幕统计 // ===================== -function renderDanmuStats(data, elapsed, title, durationSeconds) { +function renderDanmuStats(data, elapsed, title, durationSeconds, filterCounts) { const comments = data.comments; const count = comments.length; @@ -901,14 +1197,12 @@ function renderDanmuStats(data, elapsed, title, durationSeconds) { const hotStart = hotIdx * segLen; const hotMoment = formatDuration(hotStart) + ' - ' + formatDuration(hotStart + segLen); - // 统计各类型数量 - let scrollCount = 0, topCount = 0, bottomCount = 0; - comments.forEach(c => { - const mode = parseDanmuMode(c.p); - if (mode === 5) topCount++; - else if (mode === 4) bottomCount++; - else scrollCount++; - }); + const coloredCount = comments.reduce((sum, comment) => { + if (!comment || !comment.p) return sum; + const color = parseInt(String(comment.p).split(',')[2], 10); + return Number.isFinite(color) && color !== 16777215 ? sum + 1 : sum; + }, 0); + const coloredRatio = count > 0 ? ((coloredCount / count) * 100).toFixed(1) + '%' : '--'; const container = document.getElementById('danmu-stats'); container.innerHTML = @@ -918,8 +1212,8 @@ function renderDanmuStats(data, elapsed, title, durationSeconds) { '
' + formatDuration(maxTime) + '
时长
' + '
' + hotMoment + '
高能时刻
' + '
' + avgDensity + ' 条/分
平均密度
' + - '
' + elapsed + 's
匹配时长
' + - '
' + scrollCount + ' / ' + topCount + ' / ' + bottomCount + '
滚动 / 顶部 / 底部
' + + '
' + elapsed + 's
请求耗时
' + + '
' + coloredRatio + '
彩色占比
' + '
'; } @@ -929,7 +1223,7 @@ function renderDanmuStats(data, elapsed, title, durationSeconds) { function renderDanmuHeatmap(comments, durationSeconds) { const container = document.getElementById('danmu-heatmap'); const maxTime = getEffectiveDuration(comments, durationSeconds); - if (maxTime === 0) { container.innerHTML = '

无数据

'; return; } + if (maxTime === 0) { container.innerHTML = '

无数据

'; return; } const barCount = Math.min(60, Math.max(20, Math.ceil(maxTime / 30))); const segLen = maxTime / barCount; @@ -942,7 +1236,8 @@ function renderDanmuHeatmap(comments, durationSeconds) { }); const maxSeg = Math.max(...segs, 1); - let html = '
'; + let html = '
'; + html += '
'; for (let i = 0; i < barCount; i++) { const pct = Math.max(2, (segs[i] / maxSeg) * 100); const intensity = segs[i] / maxSeg; @@ -950,12 +1245,121 @@ function renderDanmuHeatmap(comments, durationSeconds) { const r = Math.round(66 + intensity * 189); const g = Math.round(126 - intensity * 80); const b = Math.round(234 - intensity * 180); - const timeLabel = formatDuration(i * segLen); - html += '
'; + const start = i * segLen; + const end = Math.min((i + 1) * segLen, maxTime); + const timeLabel = formatDuration(start) + ' - ' + formatDuration(end); + html += '
'; } html += '
'; + html += '
'; + html += '
'; html += '
00:00' + formatDuration(maxTime / 2) + '' + formatDuration(maxTime) + '
'; container.innerHTML = html; + bindDanmuHeatmapScrub(container, segs, segLen, maxTime); +} + +function bindDanmuHeatmapScrub(container, segs, segLen, maxTime) { + const interactive = container.querySelector('.danmu-heatmap-interactive'); + const bars = container.querySelector('.heatmap-bars'); + const tooltip = container.querySelector('.danmu-heatmap-tooltip'); + const indicator = container.querySelector('.danmu-heatmap-indicator'); + if (!interactive || !bars || !tooltip || !indicator) return; + + let rafId = null; + let latestEvent = null; + const scheduleUpdate = event => { + latestEvent = event; + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = null; + if (!latestEvent) return; + updateDanmuHeatmapTooltip(latestEvent, interactive, bars, tooltip, indicator, segs, segLen, maxTime); + }); + }; + + const hideTooltip = () => hideDanmuHeatmapTooltip(bars, tooltip, indicator, () => { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + latestEvent = null; + }); + + interactive.addEventListener('pointermove', scheduleUpdate, { passive: true }); + interactive.addEventListener('pointerdown', scheduleUpdate, { passive: true }); + interactive.addEventListener('pointerup', hideTooltip, { passive: true }); + interactive.addEventListener('pointercancel', hideTooltip, { passive: true }); + interactive.addEventListener('pointerleave', hideTooltip); +} + +function hideDanmuHeatmapTooltip(bars, tooltip, indicator, beforeHide) { + if (beforeHide) beforeHide(); + tooltip.classList.remove('active'); + indicator.classList.remove('active'); + const activeBar = bars.querySelector('.heatmap-bar.active'); + if (activeBar) activeBar.classList.remove('active'); +} + +function updateDanmuHeatmapTooltip(event, interactive, bars, tooltip, indicator, segs, segLen, maxTime) { + if (!event) return; + const rect = bars.getBoundingClientRect(); + if (!rect.width) return; + const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const index = Math.max(0, Math.min(segs.length - 1, Math.floor(ratio * segs.length))); + const start = index * segLen; + const end = Math.min((index + 1) * segLen, maxTime); + const leftPct = ((index + 0.5) / segs.length) * 100; + + const activeBar = bars.querySelector('.heatmap-bar.active'); + if (activeBar) activeBar.classList.remove('active'); + const currentBar = bars.querySelector('.heatmap-bar[data-index="' + index + '"]'); + if (currentBar) currentBar.classList.add('active'); + + tooltip.innerHTML = '' + segs[index] + '条弹幕' + formatDuration(start) + ' - ' + formatDuration(end) + ''; + tooltip.classList.add('active'); + indicator.classList.add('active'); + + const clampedLeft = clampHeatmapTooltipLeft(interactive, tooltip, leftPct); + tooltip.style.left = clampedLeft + 'px'; + indicator.style.left = leftPct + '%'; +} + +function clampHeatmapTooltipLeft(interactive, tooltip, leftPct) { + const width = interactive.clientWidth || 0; + const tooltipWidth = tooltip.offsetWidth || 0; + const padding = 8; + const rawLeft = (leftPct / 100) * width; + const minLeft = (tooltipWidth / 2) + padding; + const maxLeft = width - (tooltipWidth / 2) - padding; + if (width <= tooltipWidth + padding * 2) return width / 2; + return Math.max(minLeft, Math.min(maxLeft, rawLeft)); +} + +function getDanmuFilterCounts(comments) { + const counts = { all: comments.length, scroll: 0, top: 0, bottom: 0 }; + comments.forEach(c => { + const mode = parseDanmuMode(c.p); + if (mode === 5) counts.top++; + else if (mode === 4) counts.bottom++; + else counts.scroll++; + }); + return counts; +} + +function renderDanmuFilterTabs(counts) { + const items = [ + { key: 'all', label: '全部' }, + { key: 'scroll', label: '滚动' }, + { key: 'top', label: '顶部' }, + { key: 'bottom', label: '底部' } + ]; + let html = '
'; + items.forEach(item => { + const active = item.key === 'all' ? ' active' : ''; + html += ''; + }); + html += '
'; + return html; } // ===================== @@ -979,7 +1383,8 @@ function applyDanmuFilter() { function filterDanmuList(type, event) { document.querySelectorAll('.danmu-filter-tab').forEach(btn => btn.classList.remove('active')); - event.target.classList.add('active'); + const tab = event.currentTarget || event.target.closest('.danmu-filter-tab'); + if (tab) tab.classList.add('active'); danmuTestState.currentFilter = type; applyDanmuFilter(); renderDanmuList(); @@ -1000,7 +1405,7 @@ function renderDanmuList() { html += '
' + '' + formatDuration(time) + '' + - '' + + '' + '' + modeLabel + '' + '' + escapeHtml(c.m) + '' + '
'; @@ -1008,7 +1413,7 @@ function renderDanmuList() { const container = document.getElementById('danmu-list'); if (danmuTestState.displayedCount === 0) { - container.innerHTML = html || '

无弹幕数据

'; + container.innerHTML = html || '

无弹幕数据

'; } else { container.insertAdjacentHTML('beforeend', html); }