新增弹幕测试功能 (#223)

* 新增弹幕测试功能

接口调试页面新增「弹幕测试」标签页,包含:
- 自动匹配测试:输入文件名自动匹配并获取弹幕,模拟播放器自动加载流程
- 手动匹配测试:搜索动漫 → 选择番剧 → 选择剧集 → 获取弹幕
- 弹幕统计:弹幕数、时长、高能时刻、平均密度、匹配时长、类型分布
- 弹幕热力图:按时间段可视化弹幕密度分布
- 弹幕列表:支持全部/滚动/顶部/底部过滤,显示颜色和类型标签,懒加载每次100条
- 导出功能:支持导出JSON和XML格式弹幕文件
- 异常时间处理:使用P99分位数剔除异常时间点
- 视图导航优化:搜索结果/剧集详情/弹幕详情逐级切换,带返回按钮和加载动画

* 优化弹幕测试工具栏样式

- 返回按钮文字缩短,避免挤压导出按钮换行
- 导出按钮改蓝底白字,统一风格
- 导出按钮自适应布局
This commit is contained in:
lilixu3 authored and GitHub committed 2026-03-10 22:58:05 +08:00
1 parent 80953cea3c
commit 8b2e708ebc
3 files changed
+964 -25

No files matched your search

+364 -1
View File
@@ -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;
}
}
`;
+542 -4
View File
@@ -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 = '<span class="loading-spinner-small"></span>';
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 '<button class="btn btn-back" onclick="' + onclick + '">&larr; ' + escapeHtml(text) + '</button>';
}
// =====================
// 自动匹配测试
// =====================
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 = '<h3 style="margin:15px 0 10px;">搜索结果</h3><div class="anime-grid">';
animes.forEach(anime => {
const img = anime.imageUrl || 'https://placehold.co/150x200?text=No+Image';
html += '<div class="anime-item" onclick="manualGetBangumi(' + anime.animeId + ')">';
html += '<img src="' + img + '" alt="' + escapeHtml(anime.animeTitle) + '" referrerpolicy="no-referrer" class="anime-item-img">';
html += '<h4 class="anime-title">' + escapeHtml(anime.animeTitle) + ' - 共' + (anime.episodeCount || '?') + '集</h4>';
html += '</div>';
});
html += '</div>';
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 += '<h3 style="margin:15px 0 10px;">剧集列表</h3>';
html += '<h4 class="text-yellow-gold">' + escapeHtml(animeTitle) + '</h4>';
html += '<div class="episode-list-container">';
episodes.forEach(ep => {
const title = escapeHtml(animeTitle) + ' 第' + ep.episodeNumber + '集';
html += '<div class="episode-item">';
html += '<div class="episode-item-content"><strong>第' + ep.episodeNumber + '集</strong> - ' + escapeHtml(ep.episodeTitle || '无标题') + '</div>';
html += '<button class="btn btn-success btn-sm" onclick="fetchDanmuForTest(' + ep.episodeId + ', \\'' + title + '\\', \\'manual\\')">获取弹幕</button>';
html += '</div>';
});
html += '</div>';
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 = '<div class="danmu-loading"><div class="loading-spinner"></div><div class="loading-text">正在获取弹幕数据...</div></div>';
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 = '<div class="danmu-result-toolbar">';
if (source === 'manual') {
toolbarHtml += backBtnHtml('返回列表', 'backToEpisodeList()');
}
toolbarHtml += '<div class="danmu-export-btns">' +
'<button class="btn btn-sm btn-primary" onclick="exportDanmu(\\'json\\')">导出 JSON</button>' +
'<button class="btn btn-sm btn-primary" onclick="exportDanmu(\\'xml\\')">导出 XML</button>' +
'</div></div>';
resultArea.innerHTML =
toolbarHtml +
'<div class="danmu-stats" id="danmu-stats"></div>' +
'<div class="danmu-heatmap-container"><h3 style="margin:0 0 10px;">弹幕热力图</h3><div class="danmu-heatmap" id="danmu-heatmap"></div></div>' +
'<div class="danmu-list-area"><h3 style="margin:0 0 10px;">弹幕列表</h3>' +
'<div class="danmu-filter-tabs">' +
'<button class="danmu-filter-tab active" onclick="filterDanmuList(\\'all\\', event)">全部</button>' +
'<button class="danmu-filter-tab" onclick="filterDanmuList(\\'scroll\\', event)">滚动</button>' +
'<button class="danmu-filter-tab" onclick="filterDanmuList(\\'top\\', event)">顶部</button>' +
'<button class="danmu-filter-tab" onclick="filterDanmuList(\\'bottom\\', event)">底部</button>' +
'</div>' +
'<div class="danmu-list" id="danmu-list"></div>' +
'<button class="btn btn-primary danmu-load-more" id="danmu-load-more" onclick="loadMoreDanmu()" style="display:none;width:100%;margin-top:10px;">加载更多</button>' +
'</div>';
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 =
'<div class="danmu-stats-title">' + escapeHtml(title) + '</div>' +
'<div class="danmu-stats-grid">' +
'<div class="danmu-stat-card"><div class="stat-value">' + count + '</div><div class="stat-label">弹幕数</div></div>' +
'<div class="danmu-stat-card"><div class="stat-value">' + formatDuration(maxTime) + '</div><div class="stat-label">时长</div></div>' +
'<div class="danmu-stat-card"><div class="stat-value">' + hotMoment + '</div><div class="stat-label">高能时刻</div></div>' +
'<div class="danmu-stat-card"><div class="stat-value">' + avgDensity + ' 条/分</div><div class="stat-label">平均密度</div></div>' +
'<div class="danmu-stat-card"><div class="stat-value">' + elapsed + 's</div><div class="stat-label">匹配时长</div></div>' +
'<div class="danmu-stat-card"><div class="stat-value">' + scrollCount + ' / ' + topCount + ' / ' + bottomCount + '</div><div class="stat-label">滚动 / 顶部 / 底部</div></div>' +
'</div>';
}
// =====================
// 弹幕热力图
// =====================
function renderDanmuHeatmap(comments) {
const container = document.getElementById('danmu-heatmap');
const maxTime = getEffectiveDuration(comments);
if (maxTime === 0) { container.innerHTML = '<p style="color:#999;">无数据</p>'; 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 = '<div class="heatmap-bars">';
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 += '<div class="heatmap-bar" style="height:' + pct + '%;background:rgb(' + r + ',' + g + ',' + b + ');" title="' + timeLabel + ' | ' + segs[i] + '条弹幕"></div>';
}
html += '</div>';
html += '<div class="heatmap-axis"><span>00:00</span><span>' + formatDuration(maxTime / 2) + '</span><span>' + formatDuration(maxTime) + '</span></div>';
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 += '<div class="danmu-item">' +
'<span class="danmu-time">' + formatDuration(time) + '</span>' +
'<span class="danmu-color-dot" style="background:' + hexColor + ';" title="' + hexColor + '"></span>' +
'<span class="danmu-mode-tag danmu-mode-' + (mode === 5 ? 'top' : mode === 4 ? 'bottom' : 'scroll') + '">' + modeLabel + '</span>' +
'<span class="danmu-text">' + escapeHtml(c.m) + '</span>' +
'</div>';
}
const container = document.getElementById('danmu-list');
if (danmuTestState.displayedCount === 0) {
container.innerHTML = html || '<p style="color:#999;text-align:center;padding:20px;">无弹幕数据</p>';
} 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();
}
`;
+58 -20
View File
@@ -97,30 +97,68 @@ export const HTML_TEMPLATE = /* html */ `
<!-- 接口调试 -->
<div class="section" id="api-section">
<h2>接口调试</h2>
<div class="api-selector">
<div class="form-group">
<label>选择接口</label>
<select id="api-select" onchange="loadApiParams()">
<option value="">-- 请选择接口 --</option>
<option value="searchAnime">搜索动漫 - /api/v2/search/anime</option>
<option value="searchEpisodes">搜索剧集 - /api/v2/search/episodes</option>
<option value="matchAnime">匹配动漫 - /api/v2/match</option>
<option value="getBangumi">获取番剧详情 - /api/v2/bangumi/:animeId</option>
<option value="getComment">获取弹幕 - /api/v2/comment/:commentId</option>
<option value="getSegmentComment">获取分片弹幕 - /api/v2/segmentcomment</option>
</select>
<div class="api-top-tabs">
<button class="api-top-tab active" onclick="switchApiTopTab('debug', event)">接口调试</button>
<button class="api-top-tab" onclick="switchApiTopTab('danmu-test', event)">弹幕测试</button>
</div>
<div class="api-tab-content active" id="api-debug-content">
<div class="api-selector">
<div class="form-group">
<label>选择接口</label>
<select id="api-select" onchange="loadApiParams()">
<option value="">-- 请选择接口 --</option>
<option value="searchAnime">搜索动漫 - /api/v2/search/anime</option>
<option value="searchEpisodes">搜索剧集 - /api/v2/search/episodes</option>
<option value="matchAnime">匹配动漫 - /api/v2/match</option>
<option value="getBangumi">获取番剧详情 - /api/v2/bangumi/:animeId</option>
<option value="getComment">获取弹幕 - /api/v2/comment/:commentId</option>
<option value="getSegmentComment">获取分片弹幕 - /api/v2/segmentcomment</option>
</select>
</div>
</div>
<div class="api-params" id="api-params" style="display: none;">
<h3 style="margin-bottom: 15px;">接口参数</h3>
<div id="params-form"></div>
<button class="btn btn-success" onclick="testApi()">发送请求</button>
</div>
<div id="api-response-container" style="display: none;">
<h3 style="margin: 20px 0 10px;">响应结果</h3>
<div class="api-response" id="api-response"></div>
</div>
</div>
<div class="api-params" id="api-params" style="display: none;">
<h3 style="margin-bottom: 15px;">接口参数</h3>
<div id="params-form"></div>
<button class="btn btn-success" onclick="testApi()">发送请求</button>
</div>
<div class="api-tab-content" id="danmu-test-content">
<div class="danmu-test-tabs">
<button class="danmu-test-tab active" onclick="switchDanmuTestTab('auto', event)">自动匹配测试</button>
<button class="danmu-test-tab" onclick="switchDanmuTestTab('manual', event)">手动匹配测试</button>
</div>
<div id="api-response-container" style="display: none;">
<h3 style="margin: 20px 0 10px;">响应结果</h3>
<div class="api-response" id="api-response"></div>
<div class="danmu-test-panel active" id="auto-match-panel">
<p style="color: #666; margin-bottom: 15px;">模拟播放器自动匹配流程:输入文件名 → 匹配剧集 → 获取弹幕</p>
<div class="form-group" style="margin-bottom: 15px;">
<label>文件名</label>
<div style="display:flex;gap:10px;margin-top:5px;">
<input type="text" id="auto-match-filename" placeholder="示例: 生万物 S02E08, 无忧渡.S02E08.2160p.WEB-DL" style="flex:1;">
<button class="btn btn-success" id="auto-match-btn" onclick="autoMatchTest()">开始匹配</button>
</div>
</div>
</div>
<div class="danmu-test-panel" id="manual-match-panel">
<p style="color: #666; margin-bottom: 15px;">模拟播放器手动搜索流程:搜索动漫 → 选择番剧 → 选择剧集 → 获取弹幕</p>
<div class="form-group" style="margin-bottom: 15px;">
<label>搜索关键字</label>
<div style="display:flex;gap:10px;margin-top:5px;">
<input type="text" id="manual-search-keyword" placeholder="请输入动漫名称" style="flex:1;">
<button class="btn btn-primary" id="manual-search-btn" onclick="manualSearchAnime()">搜索</button>
</div>
</div>
<div id="manual-anime-list" style="display:none;"></div>
<div id="manual-episode-list" style="display:none;"></div>
</div>
<div id="danmu-result-area" style="display:none;"></div>
</div>
</div>