From 8b2e708ebc6d1faa210352738e5d7ad3af6894c0 Mon Sep 17 00:00:00 2001 From: lilixu3 Date: Tue, 10 Mar 2026 22:58:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BC=B9=E5=B9=95=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=8A=9F=E8=83=BD=20(#223)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增弹幕测试功能 接口调试页面新增「弹幕测试」标签页,包含: - 自动匹配测试:输入文件名自动匹配并获取弹幕,模拟播放器自动加载流程 - 手动匹配测试:搜索动漫 → 选择番剧 → 选择剧集 → 获取弹幕 - 弹幕统计:弹幕数、时长、高能时刻、平均密度、匹配时长、类型分布 - 弹幕热力图:按时间段可视化弹幕密度分布 - 弹幕列表:支持全部/滚动/顶部/底部过滤,显示颜色和类型标签,懒加载每次100条 - 导出功能:支持导出JSON和XML格式弹幕文件 - 异常时间处理:使用P99分位数剔除异常时间点 - 视图导航优化:搜索结果/剧集详情/弹幕详情逐级切换,带返回按钮和加载动画 * 优化弹幕测试工具栏样式 - 返回按钮文字缩短,避免挤压导出按钮换行 - 导出按钮改蓝底白字,统一风格 - 导出按钮自适应布局 --- danmu_api/ui/css/components.css.js | 365 ++++++++++++++++++- danmu_api/ui/js/apitest.js | 546 ++++++++++++++++++++++++++++- danmu_api/ui/template.js | 78 +++-- 3 files changed, 964 insertions(+), 25 deletions(-) diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js index 0a6edc6..967afaf 100644 --- a/danmu_api/ui/css/components.css.js +++ b/danmu_api/ui/css/components.css.js @@ -1079,7 +1079,7 @@ export const componentsCssContent = /* css */ ` flex-direction: column; align-items: stretch; } - + .record-method, .record-interface, .record-ip { @@ -1087,4 +1087,367 @@ export const componentsCssContent = /* css */ ` box-sizing: border-box; } } + +/* ===================== */ +/* 弹幕测试相关样式 */ +/* ===================== */ + +/* 顶级标签页 */ +.api-top-tabs { + display: flex; + gap: 0; + margin-bottom: 20px; + border-bottom: 2px solid #e0e0e0; +} + +.api-top-tab { + padding: 10px 24px; + background: none; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 15px; + font-weight: 500; + color: #666; + transition: all 0.3s; + margin-bottom: -2px; +} + +.api-top-tab:hover { + color: #667eea; +} + +.api-top-tab.active { + color: #667eea; + border-bottom-color: #667eea; + font-weight: bold; +} + +.api-tab-content { + display: none; +} + +.api-tab-content.active { + display: block; +} + +/* 弹幕测试子标签 */ +.danmu-test-tabs { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.danmu-test-tab { + padding: 8px 20px; + background: #f0f0f0; + border: 2px solid transparent; + border-radius: 20px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.3s; + color: #555; +} + +.danmu-test-tab:hover { + background: #e0e0e0; +} + +.danmu-test-tab.active { + background: #667eea; + color: white; + border-color: #667eea; +} + +.danmu-test-panel { + display: none; +} + +.danmu-test-panel.active { + display: block; +} + +/* 弹幕统计卡片 */ +.danmu-stats { + margin-bottom: 15px; +} + +.danmu-stats-title { + font-size: 16px; + font-weight: bold; + color: #667eea; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #eee; +} + +.danmu-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; +} + +.danmu-stat-card { + background: #f8f9fa; + border-radius: 10px; + padding: 15px; + text-align: center; + border: 1px solid #eee; + transition: transform 0.2s; +} + +.danmu-stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.08); +} + +.stat-value { + font-size: 20px; + font-weight: bold; + color: #333; + margin-bottom: 4px; + word-break: break-all; +} + +.stat-label { + font-size: 12px; + color: #999; +} + +/* 弹幕热力图 */ +.danmu-heatmap-container { + margin-bottom: 15px; + background: #f8f9fa; + border-radius: 10px; + padding: 15px; +} + +.danmu-heatmap-container h3 { + margin: 0 0 10px; + font-size: 15px; +} + +.heatmap-bars { + display: flex; + align-items: flex-end; + gap: 2px; + height: 100px; + padding: 0 2px; +} + +.heatmap-bar { + flex: 1; + min-width: 3px; + border-radius: 2px 2px 0 0; + transition: opacity 0.2s; + cursor: pointer; +} + +.heatmap-bar:hover { + opacity: 0.8; +} + +.heatmap-axis { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: 11px; + color: #999; +} + +/* 弹幕过滤标签 */ +.danmu-filter-tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.danmu-filter-tab { + padding: 6px 16px; + background: #f0f0f0; + border: 1px solid #ddd; + border-radius: 15px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + color: #555; +} + +.danmu-filter-tab:hover { + background: #e0e0e0; +} + +.danmu-filter-tab.active { + background: #667eea; + color: white; + border-color: #667eea; +} + +/* 弹幕列表 */ +.danmu-list-area { + background: #f8f9fa; + border-radius: 10px; + padding: 15px; +} + +.danmu-list-area h3 { + margin: 0 0 10px; + font-size: 15px; +} + +.danmu-list { + max-height: 500px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 8px; + background: white; +} + +.danmu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid #f0f0f0; + font-size: 13px; + transition: background 0.15s; +} + +.danmu-item:last-child { + border-bottom: none; +} + +.danmu-item:hover { + background: #f8f9ff; +} + +.danmu-time { + color: #999; + font-family: 'Courier New', monospace; + font-size: 12px; + min-width: 50px; + flex-shrink: 0; +} + +.danmu-color-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + border: 1px solid rgba(0,0,0,0.1); +} + +.danmu-mode-tag { + font-size: 11px; + padding: 2px 6px; + border-radius: 8px; + flex-shrink: 0; + font-weight: 500; +} + +.danmu-mode-scroll { + background: #e3f2fd; + color: #1976d2; +} + +.danmu-mode-top { + background: #fce4ec; + color: #c62828; +} + +.danmu-mode-bottom { + background: #e8f5e9; + color: #2e7d32; +} + +.danmu-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #333; +} + +.danmu-load-more { + margin-top: 10px; +} + +/* 返回按钮 */ +.btn-back { + padding: 8px 16px; + background: #f0f0f0; + border: 1px solid #ddd; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + color: #555; + transition: all 0.2s; + margin-bottom: 15px; + display: inline-block; +} + +.btn-back:hover { + background: #e0e0e0; + color: #333; +} + +/* 弹幕加载动画 */ +.danmu-loading { + text-align: center; + padding: 60px 20px; +} + +.danmu-loading .loading-spinner { + margin: 0 auto 15px; +} + +.danmu-loading .loading-text { + color: #666; + font-size: 15px; +} + +/* 弹幕结果工具栏(返回+导出同行) */ +.danmu-result-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + flex-wrap: wrap; + gap: 10px; +} + +.danmu-result-toolbar .btn-back { + margin-bottom: 0; +} + +.danmu-export-btns { + display: flex; + gap: 8px; + margin-left: auto; +} + +/* 弹幕测试移动端适配 */ +@media (max-width: 768px) { + .danmu-stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .api-top-tab { + padding: 8px 16px; + font-size: 14px; + } + + .danmu-test-tabs { + flex-wrap: wrap; + } + + .stat-value { + font-size: 16px; + } + + .heatmap-bars { + height: 70px; + } +} `; diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js index e79ec51..15bf6ea 100644 --- a/danmu_api/ui/js/apitest.js +++ b/danmu_api/ui/js/apitest.js @@ -57,15 +57,35 @@ const apiConfigs = { } }; +// 弹幕测试全局状态 +let danmuTestState = { + allComments: [], + filteredComments: [], + currentFilter: 'all', + displayedCount: 0, + pageSize: 100, + currentEpisodeId: null, + currentTitle: '' +}; + // 初始化接口调试界面 function initApiTestInterface() { - // 为API选择下拉框添加回车事件监听 const apiSelect = document.getElementById('api-select'); if (apiSelect) { apiSelect.addEventListener('keypress', function(event) { - if (event.key === 'Enter') { - loadApiParams(); - } + if (event.key === 'Enter') { loadApiParams(); } + }); + } + const autoInput = document.getElementById('auto-match-filename'); + if (autoInput) { + autoInput.addEventListener('keypress', function(event) { + if (event.key === 'Enter') { autoMatchTest(); } + }); + } + const manualInput = document.getElementById('manual-search-keyword'); + if (manualInput) { + manualInput.addEventListener('keypress', function(event) { + if (event.key === 'Enter') { manualSearchAnime(); } }); } } @@ -344,4 +364,522 @@ function testApi() { sendButton.disabled = false; }); } + +// ===================== +// 标签页切换 +// ===================== +function switchApiTopTab(tab, event) { + document.querySelectorAll('.api-top-tab').forEach(btn => btn.classList.remove('active')); + document.querySelectorAll('.api-tab-content').forEach(el => el.classList.remove('active')); + event.target.classList.add('active'); + if (tab === 'debug') { + document.getElementById('api-debug-content').classList.add('active'); + } else { + document.getElementById('danmu-test-content').classList.add('active'); + } +} + +function switchDanmuTestTab(tab, event) { + document.querySelectorAll('.danmu-test-tab').forEach(btn => btn.classList.remove('active')); + document.querySelectorAll('.danmu-test-panel').forEach(el => el.classList.remove('active')); + event.target.classList.add('active'); + if (tab === 'auto') { + document.getElementById('auto-match-panel').classList.add('active'); + } else { + document.getElementById('manual-match-panel').classList.add('active'); + } +} + +// ===================== +// 工具函数 +// ===================== +function decColorToHex(dec) { + const n = parseInt(dec) || 16777215; + return '#' + n.toString(16).padStart(6, '0'); +} + +function parseDanmuMode(p) { + const parts = p.split(','); + return parseInt(parts[1]) || 1; +} + +function parseDanmuColor(p) { + const parts = p.split(','); + return parseInt(parts[2]) || 16777215; +} + +function parseDanmuTime(p) { + return parseFloat(p.split(',')[0]) || 0; +} + +function getModeLabel(mode) { + switch (mode) { + case 4: return '底部'; + case 5: return '顶部'; + default: return '滚动'; + } +} + +function formatDuration(seconds) { + const s = Math.floor(seconds); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0'); + return m + ':' + String(sec).padStart(2,'0'); +} + +// 用P99分位数计算有效时长,剔除异常时间点 +function getEffectiveDuration(comments) { + const times = comments.map(c => c.t !== undefined ? c.t : parseDanmuTime(c.p)).sort((a, b) => a - b); + if (times.length === 0) return 0; + const idx = Math.floor(times.length * 0.99); + return times[Math.min(idx, times.length - 1)]; +} + +function setBtnLoading(btn, loading) { + if (!btn) return; + if (loading) { + btn._origText = btn.innerHTML; + btn.innerHTML = ''; + btn.disabled = true; + } else { + btn.innerHTML = btn._origText || '按钮'; + btn.disabled = false; + } +} + +// 通用:显示指定面板,隐藏同级其他面板 +function showDanmuView(showIds, hideIds) { + hideIds.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); + showIds.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'block'; }); +} + +// 生成返回按钮HTML +function backBtnHtml(text, onclick) { + return ''; +} + +// ===================== +// 自动匹配测试 +// ===================== +async function autoMatchTest() { + const fileName = document.getElementById('auto-match-filename').value.trim(); + if (!fileName) { customAlert('请输入文件名'); return; } + + const btn = document.getElementById('auto-match-btn'); + setBtnLoading(btn, true); + document.getElementById('danmu-result-area').style.display = 'none'; + addLog('自动匹配测试: ' + fileName, 'info'); + + try { + const resp = await fetch(buildApiUrl('/api/v2/match'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: fileName }) + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const data = await resp.json(); + + if (data.isMatched && data.matches && data.matches.length > 0) { + const best = data.matches[0]; + const title = (best.animeTitle || '') + ' ' + (best.episodeTitle || ''); + addLog('自动匹配命中: ' + title + ' (共' + data.matches.length + '个结果,取第1个)', 'success'); + setBtnLoading(btn, false); + fetchDanmuForTest(best.episodeId, title, 'auto'); + return; + } else { + customAlert('未匹配到任何结果'); + addLog('自动匹配无结果', 'warn'); + } + } catch (e) { + customAlert('匹配失败: ' + e.message); + addLog('自动匹配失败: ' + e.message, 'error'); + } finally { + setBtnLoading(btn, false); + } +} + +// ===================== +// 手动匹配测试 +// ===================== +async function manualSearchAnime() { + const keyword = document.getElementById('manual-search-keyword').value.trim(); + if (!keyword) { customAlert('请输入搜索关键字'); return; } + + const btn = document.getElementById('manual-search-btn'); + setBtnLoading(btn, 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'); + + try { + const resp = await fetch(buildApiUrl('/api/v2/search/anime?keyword=' + encodeURIComponent(keyword))); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const data = await resp.json(); + + if (data.success && data.animes && data.animes.length > 0) { + displayManualAnimeList(data.animes); + addLog('搜索到 ' + data.animes.length + ' 个动漫', 'success'); + } else { + customAlert('未找到相关动漫'); + addLog('搜索无结果', 'warn'); + } + } catch (e) { + customAlert('搜索失败: ' + e.message); + addLog('搜索失败: ' + e.message, 'error'); + } finally { + setBtnLoading(btn, false); + } +} + +function displayManualAnimeList(animes) { + const container = document.getElementById('manual-anime-list'); + let html = '

搜索结果

'; + animes.forEach(anime => { + const img = anime.imageUrl || 'https://placehold.co/150x200?text=No+Image'; + html += '
'; + html += '' + escapeHtml(anime.animeTitle) + ''; + html += '

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

'; + html += '
'; + }); + html += '
'; + container.innerHTML = html; + container.style.display = 'block'; +} + +async function manualGetBangumi(animeId) { + addLog('获取番剧详情: ' + animeId, 'info'); + // 隐藏搜索结果,显示剧集列表区域 + showDanmuView(['manual-episode-list'], ['manual-anime-list', 'danmu-result-area']); + + try { + const resp = await fetch(buildApiUrl('/api/v2/bangumi/' + animeId)); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const data = await resp.json(); + + if (data.success && data.bangumi && data.bangumi.episodes) { + displayManualEpisodeList(data.bangumi.animeTitle, data.bangumi.episodes); + addLog('获取到 ' + data.bangumi.episodes.length + ' 个剧集', 'success'); + } else { + customAlert('该动漫暂无剧集信息'); + // 恢复搜索结果 + showDanmuView(['manual-anime-list'], ['manual-episode-list']); + } + } catch (e) { + customAlert('获取番剧详情失败: ' + e.message); + addLog('获取番剧详情失败: ' + e.message, 'error'); + showDanmuView(['manual-anime-list'], ['manual-episode-list']); + } +} + +function displayManualEpisodeList(animeTitle, episodes) { + const container = document.getElementById('manual-episode-list'); + let html = backBtnHtml('返回搜索结果', 'backToManualSearch()'); + html += '

剧集列表

'; + html += '

' + escapeHtml(animeTitle) + '

'; + html += '
'; + episodes.forEach(ep => { + const title = escapeHtml(animeTitle) + ' 第' + ep.episodeNumber + '集'; + html += '
'; + html += '
第' + ep.episodeNumber + '集 - ' + escapeHtml(ep.episodeTitle || '无标题') + '
'; + html += ''; + html += '
'; + }); + html += '
'; + container.innerHTML = html; + container.style.display = 'block'; + setTimeout(() => container.scrollIntoView({ behavior: 'smooth', block: 'start' }), 10); +} + +// 返回搜索结果 +function backToManualSearch() { + showDanmuView(['manual-anime-list'], ['manual-episode-list', 'danmu-result-area']); +} + +// 返回剧集列表 +function backToEpisodeList() { + showDanmuView(['manual-episode-list'], ['danmu-result-area']); +} + +// ===================== +// 获取弹幕并展示结果 +// ===================== +async function fetchDanmuForTest(episodeId, title, source) { + addLog('获取弹幕: ' + episodeId + ' (' + title + ')', 'info'); + danmuTestState.currentEpisodeId = episodeId; + danmuTestState.currentTitle = title; + + // 隐藏上级面板 + if (source === 'manual') { + showDanmuView([], ['manual-episode-list', 'manual-anime-list', 'danmu-result-area']); + } else { + showDanmuView([], ['danmu-result-area']); + } + + // 显示加载动画 + const resultArea = document.getElementById('danmu-result-area'); + resultArea.innerHTML = '
正在获取弹幕数据...
'; + resultArea.style.display = 'block'; + + const startTime = performance.now(); + try { + const resp = await fetch(buildApiUrl('/api/v2/comment/' + episodeId)); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const data = await resp.json(); + const elapsed = ((performance.now() - startTime) / 1000).toFixed(2); + + if (!data.comments || data.comments.length === 0) { + customAlert('该剧集暂无弹幕数据'); + addLog('无弹幕数据', 'warn'); + resultArea.style.display = 'none'; + if (source === 'manual') backToEpisodeList(); + return; + } + + addLog('获取到 ' + data.comments.length + ' 条弹幕, 耗时 ' + elapsed + 's', 'success'); + + danmuTestState.allComments = data.comments; + danmuTestState.currentFilter = 'all'; + danmuTestState.displayedCount = 0; + + // 重建结果区内容:auto模式只有导出按钮,manual模式有返回+导出 + let toolbarHtml = '
'; + if (source === 'manual') { + toolbarHtml += backBtnHtml('返回列表', 'backToEpisodeList()'); + } + toolbarHtml += '
' + + '' + + '' + + '
'; + + resultArea.innerHTML = + toolbarHtml + + '
' + + '

弹幕热力图

' + + '

弹幕列表

' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '
'; + + applyDanmuFilter(); + renderDanmuStats(data, elapsed, title); + renderDanmuHeatmap(data.comments); + renderDanmuList(); + + setTimeout(() => resultArea.scrollIntoView({ behavior: 'smooth', block: 'start' }), 10); + } catch (e) { + customAlert('获取弹幕失败: ' + e.message); + addLog('获取弹幕失败: ' + e.message, 'error'); + resultArea.style.display = 'none'; + if (source === 'manual') backToEpisodeList(); + } +} + +// ===================== +// 导出弹幕(直接请求后端获取格式化数据) +// ===================== +async function exportDanmu(format) { + const id = danmuTestState.currentEpisodeId; + if (!id) { customAlert('无弹幕数据可导出'); return; } + + const title = danmuTestState.currentTitle || ('danmu_' + id); + // 清理文件名中的非法字符 + const safeTitle = title.replace(/[\\\\/:*?"<>|]/g, '_'); + + addLog('导出弹幕 ' + format.toUpperCase() + '...', 'info'); + try { + const resp = await fetch(buildApiUrl('/api/v2/comment/' + id + '?format=' + format)); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + + let content, mimeType; + if (format === 'xml') { + content = await resp.text(); + mimeType = 'application/xml'; + } else { + const data = await resp.json(); + content = JSON.stringify(data, null, 2); + mimeType = 'application/json'; + } + + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = safeTitle + '.' + format; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + addLog('导出弹幕 ' + format.toUpperCase() + ' 成功', 'success'); + } catch (e) { + customAlert('导出失败: ' + e.message); + addLog('导出失败: ' + e.message, 'error'); + } +} + +// ===================== +// 弹幕统计 +// ===================== +function renderDanmuStats(data, elapsed, title) { + const comments = data.comments; + const count = comments.length; + + // 用P99分位数作为有效时长 + const maxTime = getEffectiveDuration(comments); + + // 平均密度 + const durationMin = maxTime / 60; + const avgDensity = durationMin > 0 ? (count / durationMin).toFixed(1) : '0'; + + // 高能时刻:找密度最高的30秒区间 + const segLen = 30; + const segCount = Math.ceil(maxTime / segLen) || 1; + const segs = new Array(segCount).fill(0); + comments.forEach(c => { + const t = c.t !== undefined ? c.t : parseDanmuTime(c.p); + if (t > maxTime) return; // 跳过异常时间点 + const idx = Math.min(Math.floor(t / segLen), segCount - 1); + segs[idx]++; + }); + let hotIdx = 0; + for (let i = 1; i < segs.length; i++) { + if (segs[i] > segs[hotIdx]) hotIdx = i; + } + 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 container = document.getElementById('danmu-stats'); + container.innerHTML = + '
' + escapeHtml(title) + '
' + + '
' + + '
' + count + '
弹幕数
' + + '
' + formatDuration(maxTime) + '
时长
' + + '
' + hotMoment + '
高能时刻
' + + '
' + avgDensity + ' 条/分
平均密度
' + + '
' + elapsed + 's
匹配时长
' + + '
' + scrollCount + ' / ' + topCount + ' / ' + bottomCount + '
滚动 / 顶部 / 底部
' + + '
'; +} + +// ===================== +// 弹幕热力图 +// ===================== +function renderDanmuHeatmap(comments) { + const container = document.getElementById('danmu-heatmap'); + const maxTime = getEffectiveDuration(comments); + if (maxTime === 0) { container.innerHTML = '

无数据

'; return; } + + const barCount = Math.min(60, Math.max(20, Math.ceil(maxTime / 30))); + const segLen = maxTime / barCount; + const segs = new Array(barCount).fill(0); + comments.forEach(c => { + const t = c.t !== undefined ? c.t : parseDanmuTime(c.p); + if (t > maxTime) return; // 跳过异常时间点 + const idx = Math.min(Math.floor(t / segLen), barCount - 1); + segs[idx]++; + }); + + const maxSeg = Math.max(...segs, 1); + let html = '
'; + for (let i = 0; i < barCount; i++) { + const pct = Math.max(2, (segs[i] / maxSeg) * 100); + const intensity = segs[i] / maxSeg; + // 从蓝到红的渐变 + 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 += '
'; + } + html += '
'; + html += '
00:00' + formatDuration(maxTime / 2) + '' + formatDuration(maxTime) + '
'; + container.innerHTML = html; +} + +// ===================== +// 弹幕列表过滤与懒加载 +// ===================== +function applyDanmuFilter() { + const filter = danmuTestState.currentFilter; + if (filter === 'all') { + danmuTestState.filteredComments = danmuTestState.allComments; + } else { + const modeMap = { scroll: 1, top: 5, bottom: 4 }; + const targetMode = modeMap[filter]; + danmuTestState.filteredComments = danmuTestState.allComments.filter(c => { + const mode = parseDanmuMode(c.p); + if (filter === 'scroll') return mode !== 4 && mode !== 5; + return mode === targetMode; + }); + } + danmuTestState.displayedCount = 0; +} + +function filterDanmuList(type, event) { + document.querySelectorAll('.danmu-filter-tab').forEach(btn => btn.classList.remove('active')); + event.target.classList.add('active'); + danmuTestState.currentFilter = type; + applyDanmuFilter(); + renderDanmuList(); +} + +function renderDanmuList() { + const list = danmuTestState.filteredComments; + const end = Math.min(danmuTestState.displayedCount + danmuTestState.pageSize, list.length); + let html = ''; + + for (let i = danmuTestState.displayedCount; i < end; i++) { + const c = list[i]; + const time = c.t !== undefined ? c.t : parseDanmuTime(c.p); + const mode = parseDanmuMode(c.p); + const color = parseDanmuColor(c.p); + const hexColor = decColorToHex(color); + const modeLabel = getModeLabel(mode); + + html += '
' + + '' + formatDuration(time) + '' + + '' + + '' + modeLabel + '' + + '' + escapeHtml(c.m) + '' + + '
'; + } + + const container = document.getElementById('danmu-list'); + if (danmuTestState.displayedCount === 0) { + container.innerHTML = html || '

无弹幕数据

'; + } else { + container.insertAdjacentHTML('beforeend', html); + } + danmuTestState.displayedCount = end; + + // 控制加载更多按钮 + const loadMoreBtn = document.getElementById('danmu-load-more'); + if (end < list.length) { + loadMoreBtn.style.display = 'block'; + loadMoreBtn.textContent = '加载更多 (' + end + '/' + list.length + ')'; + } else { + loadMoreBtn.style.display = 'none'; + } +} + +function loadMoreDanmu() { + renderDanmuList(); +} `; diff --git a/danmu_api/ui/template.js b/danmu_api/ui/template.js index d387554..89e7536 100644 --- a/danmu_api/ui/template.js +++ b/danmu_api/ui/template.js @@ -97,30 +97,68 @@ export const HTML_TEMPLATE = /* html */ `

接口调试

-
-
- - +
+ + +
+ +
+
+
+ + +
+
+ +
- +
+
+ + +
-