优化弹幕测试热力图、筛选与统计卡片 (#343)
* 增强弹幕测试结果页调用链路体验 变动内容: - 在弹幕测试结果页增加调用链路展示,覆盖自动匹配、手动搜索、番剧详情和弹幕拉取过程。 - 将统计卡文案调整为链路耗时,并按当前请求链路汇总耗时。 - 优化热力图滑动提示、边缘防溢出、筛选标签数量显示和横向滚动体验。 - 修复切换番剧/剧集时旧调用链路追加、旧请求返回覆盖新结果、失败和空结果缺少链路信息的问题。 * 修复弹幕测试调用链路串线 变动内容: - 为自动匹配、手动搜索、番剧详情和弹幕拉取增加独立请求令牌,避免旧响应覆盖新结果。 - 传递并克隆当前调用链路,修正手动失败和空结果的返回目标。 - 清理弹幕测试静态 inline 样式,补充热力图、筛选、跳集和移动端样式。 - 收敛方法标记和热力图类名,复用弹幕类型统计并增强长文本展示。 * 移除弹幕测试详情页调用链路显示 * 弹幕测试统计改为彩色占比
This commit is contained in:
1 parent
7996e8d2b1
commit
53e2294d98
2 files changed
+639
-63
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+465
-60
@@ -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 '<button class="btn btn-back" onclick="' + onclick + '">← ' + escapeHtml(text) + '</button>';
|
||||
}
|
||||
|
||||
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/'), '/<TOKEN>/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 = '<div class="danmu-result-toolbar">';
|
||||
if (source === 'manual-episode') {
|
||||
html += backBtnHtml('返回列表', 'backToEpisodeList()');
|
||||
} else if (source === 'manual-anime') {
|
||||
html += backBtnHtml('返回搜索结果', 'backToManualSearch()');
|
||||
}
|
||||
html += '</div>';
|
||||
html += '<div class="danmu-result-error">' + escapeHtml(title || '调用失败') + ':' + escapeHtml(message || '请求失败') + '</div>';
|
||||
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 = '<h3 style="margin:15px 0 10px;">搜索结果</h3><div class="anime-grid">';
|
||||
let html = '<h3 class="danmu-section-title danmu-section-title-spaced">搜索结果</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 + ')">';
|
||||
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 += '<div class="anime-item" onclick="manualGetBangumi(' + 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>';
|
||||
@@ -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 += '<h3 style="margin:15px 0 10px;">剧集列表</h3>';
|
||||
html += '<h3 class="danmu-section-title danmu-section-title-spaced">剧集列表</h3>';
|
||||
html += '<h4 class="text-yellow-gold">' + escapeHtml(animeTitle) + '</h4>';
|
||||
|
||||
// 添加跳转到指定集数的功能
|
||||
html += \`
|
||||
<div class="jump-to-episode" style="margin-top: 15px; margin-bottom: 15px; padding: 10px; background: #f8f9fa; border-radius: 12px; display: flex; align-items: center; gap: 10px;">
|
||||
<div class="jump-to-episode">
|
||||
<span>跳转到第</span>
|
||||
<input type="number" id="jump-episode-input" placeholder="输入集数" min="1" style="padding: 8px; width: 90px; border: 1px solid #ccc; border-radius: 8px;">
|
||||
<input type="number" id="jump-episode-input" class="jump-episode-input" placeholder="输入集数" min="1">
|
||||
<span>集</span>
|
||||
<button class="btn btn-primary btn-sm" onclick="jumpToEpisode()" style="margin-left: 5px; border-radius: 8px;">跳转</button>
|
||||
<span style="margin-left: 5px; color: #666; font-size: 14px;">共\${episodes.length}集</span>
|
||||
<button class="btn btn-primary btn-sm jump-episode-btn" onclick="jumpToEpisode()">跳转</button>
|
||||
<span class="jump-episode-total">共\${episodes.length}集</span>
|
||||
</div>\`;
|
||||
|
||||
html += '<div class="episode-list-container">';
|
||||
episodes.forEach(ep => {
|
||||
const title = escapeHtml(animeTitle) + ' 第' + ep.episodeNumber + '集';
|
||||
html += '<div class="episode-item" id="episode-item-' + ep.episodeNumber + '">';
|
||||
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>';
|
||||
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 += '<div class="episode-item" id="episode-item-' + episodeNumber + '">';
|
||||
html += '<div class="episode-item-content"><strong>第' + episodeNumber + '集</strong> - ' + escapeHtml(ep.episodeTitle || '无标题') + '</div>';
|
||||
html += '<button class="btn btn-success btn-sm" onclick="fetchDanmuForTest(' + episodeId + ', ' + inlineJsString(title) + ', \\'manual\\')">获取弹幕</button>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
@@ -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) {
|
||||
'<button class="btn btn-sm btn-primary" onclick="exportDanmu(\\'xml\\')">导出 XML</button>' +
|
||||
'</div></div>';
|
||||
|
||||
const filterCounts = getDanmuFilterCounts(data.comments);
|
||||
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>' +
|
||||
renderDanmuCallTrace(requestTrace) +
|
||||
'<div class="danmu-heatmap-container"><h3 class="danmu-section-title">弹幕热力图</h3><div class="danmu-heatmap" id="danmu-heatmap"></div></div>' +
|
||||
'<div class="danmu-list-area"><h3 class="danmu-section-title">弹幕列表</h3>' +
|
||||
renderDanmuFilterTabs(filterCounts) +
|
||||
'<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>' +
|
||||
'<button class="btn btn-primary danmu-load-more" id="danmu-load-more" onclick="loadMoreDanmu()">加载更多</button>' +
|
||||
'</div>';
|
||||
|
||||
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) {
|
||||
'<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 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">' + coloredRatio + '</div><div class="stat-label">彩色占比</div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -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 = '<p style="color:#999;">无数据</p>'; return; }
|
||||
if (maxTime === 0) { container.innerHTML = '<p class="danmu-empty-text">无数据</p>'; 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 = '<div class="heatmap-bars">';
|
||||
let html = '<div class="danmu-heatmap-interactive">';
|
||||
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;
|
||||
@@ -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 += '<div class="heatmap-bar" style="height:' + pct + '%;background:rgb(' + r + ',' + g + ',' + b + ');" title="' + timeLabel + ' | ' + segs[i] + '条弹幕"></div>';
|
||||
const start = i * segLen;
|
||||
const end = Math.min((i + 1) * segLen, maxTime);
|
||||
const timeLabel = formatDuration(start) + ' - ' + formatDuration(end);
|
||||
html += '<div class="heatmap-bar" data-index="' + i + '" data-count="' + segs[i] + '" style="--heatmap-height:' + pct + '%;--heatmap-color:rgb(' + r + ',' + g + ',' + b + ');" aria-label="' + timeLabel + ' | ' + segs[i] + '条弹幕"></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '<div class="danmu-heatmap-indicator"></div><div class="danmu-heatmap-tooltip"></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;
|
||||
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 = '<strong>' + segs[index] + '</strong><span>条弹幕</span><em>' + formatDuration(start) + ' - ' + formatDuration(end) + '</em>';
|
||||
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 = '<div class="danmu-filter-tabs">';
|
||||
items.forEach(item => {
|
||||
const active = item.key === 'all' ? ' active' : '';
|
||||
html += '<button class="danmu-filter-tab' + active + '" data-count="' + counts[item.key] + '" onclick="filterDanmuList(\\'' + item.key + '\\', event)">' + item.label + '<span class="danmu-filter-count">' + counts[item.key] + '</span></button>';
|
||||
});
|
||||
html += '</div>';
|
||||
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 += '<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-color-dot" style="--danmu-color-dot:' + 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>';
|
||||
@@ -1008,7 +1413,7 @@ function renderDanmuList() {
|
||||
|
||||
const container = document.getElementById('danmu-list');
|
||||
if (danmuTestState.displayedCount === 0) {
|
||||
container.innerHTML = html || '<p style="color:#999;text-align:center;padding:20px;">无弹幕数据</p>';
|
||||
container.innerHTML = html || '<p class="danmu-empty-text danmu-empty-list">无弹幕数据</p>';
|
||||
} else {
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user