Commit abc96763 abc96763f11168a33b45d55bd9184466995cd06d by 沈秋雨

feat(dashboard): 新增音频播放器及决策类型筛选功能

- 在L2审核面板新增音频播放器组件,实现播放、进度条、时间显示等交互
- 音频播放器支持多个实例,点击播放按钮互斥播放
- 页面布局中拆分详情为粘性区域和可滚动区域,更好展示新入库歌词和召回候选音频
- 后端接口增强,返回查询和候选音频URL数据支持前端播放
- 决策过滤条件扩展,支持l1命中、l2重复、l2审核、冲突、新歌、跳过等更多分类
- 更新筛选逻辑和选项标签,提升用户筛选灵活性和准确性
1 parent ae633f56
......@@ -115,9 +115,100 @@
.content {
overflow: auto;
padding: 0;
}
.sticky-info {
position: sticky;
top: 0;
z-index: 3;
background: var(--bg);
padding: 14px 18px 0;
}
.scroll-detail {
padding: 14px 18px 24px;
}
.audio-player {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: #fbfcfd;
border: 1px solid var(--line);
border-radius: 8px;
margin-top: 10px;
}
.audio-player .play-btn {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex: none;
padding: 0;
}
.audio-player .play-btn:hover:not(:disabled) {
opacity: 0.85;
}
.audio-player.disabled .play-btn {
background: #c8d0d9;
cursor: not-allowed;
}
.audio-player.disabled .audio-progress-bar {
background: #eef1f4;
}
.audio-player.disabled .audio-time {
color: #aab4bf;
}
.audio-progress-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.audio-progress-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: #e4e8ed;
cursor: pointer;
position: relative;
overflow: hidden;
}
.audio-progress-bar .filled {
height: 100%;
background: var(--accent);
border-radius: 3px;
width: 0%;
transition: width 0.15s linear;
}
.audio-time {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
flex: none;
min-width: 80px;
text-align: right;
}
.section {
margin-bottom: 14px;
background: var(--panel);
......@@ -447,6 +538,7 @@
main { grid-template-columns: 1fr; height: auto; }
aside { border-right: 0; border-bottom: 1px solid var(--line); max-height: 420px; }
.detail-grid, .lyrics-grid { grid-template-columns: 1fr; }
.sticky-info { position: relative; padding: 10px; }
header { height: auto; min-height: 56px; flex-wrap: wrap; padding: 10px; }
.toolbar { flex-wrap: wrap; }
}
......@@ -460,13 +552,15 @@
<select id="runSelect"></select>
<label id="topKLabel" for="topKSelect">topK</label>
<select id="topKSelect"></select>
<label for="decisionFilter">决策</label>
<label for="decisionFilter">类型</label>
<select id="decisionFilter">
<option value="">全部</option>
<option value="new">new</option>
<option value="merge">merge</option>
<option value="review">review</option>
<option value="skip">skip</option>
<option value="l1_hit">L1 命中</option>
<option value="l2_duplicate">L2 重复</option>
<option value="l2_review">L2 审核</option>
<option value="conflict">L1/L2 冲突</option>
<option value="new">新歌</option>
<option value="skip">已跳过</option>
</select>
<input id="searchInput" type="search" placeholder="搜索歌名 / ID">
<label for="pageSizeSelect">每页</label>
......@@ -511,7 +605,8 @@
</aside>
<section class="content">
<div id="detail"></div>
<div id="stickyInfo" class="sticky-info"></div>
<div id="scrollDetail" class="scroll-detail"></div>
</section>
</main>
......@@ -550,7 +645,8 @@
metrics: document.getElementById('metrics'),
sampleCount: document.getElementById('sampleCount'),
queryList: document.getElementById('queryList'),
detail: document.getElementById('detail')
detail: document.getElementById('scrollDetail'),
stickyInfo: document.getElementById('stickyInfo')
};
function esc(value) {
......@@ -680,6 +776,7 @@
els.queryList.innerHTML = '<div class="empty">没有样本</div>';
state.queryId = '';
state.queryRows = [];
els.stickyInfo.innerHTML = '';
renderDetail();
return;
}
......@@ -781,9 +878,74 @@
deleted: '已删除',
};
function fmtDuration(sec) {
if (!sec || !isFinite(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function audioPlayerHtml(id, url) {
const disabled = !url;
return `
<div class="audio-player${disabled ? ' disabled' : ''}" id="${id}Player">
<button class="play-btn" id="${id}PlayBtn" ${disabled ? 'disabled' : ''}>&#9654;</button>
<div class="audio-progress-wrap">
<div class="audio-progress-bar" id="${id}ProgressBar"><div class="filled" id="${id}ProgressFilled"></div></div>
</div>
<span class="audio-time" id="${id}Time">${disabled ? '无音频' : '0:00 / 0:00'}</span>
${url ? `<audio id="${id}El" preload="metadata" src="${esc(url)}"></audio>` : ''}
</div>`;
}
function bindAudioPlayer(id) {
const audioEl = document.getElementById(`${id}El`);
const playBtn = document.getElementById(`${id}PlayBtn`);
const progressBar = document.getElementById(`${id}ProgressBar`);
const progressFilled = document.getElementById(`${id}ProgressFilled`);
const audioTime = document.getElementById(`${id}Time`);
if (!audioEl || !playBtn) return;
const pauseAllAudio = () => {
for (const el of document.querySelectorAll('.audio-player audio')) {
el.pause();
const btn = el.closest('.audio-player').querySelector('.play-btn');
if (btn) btn.innerHTML = '&#9654;';
}
};
audioEl.addEventListener('loadedmetadata', () => {
audioTime.textContent = `0:00 / ${fmtDuration(audioEl.duration)}`;
});
playBtn.addEventListener('click', () => {
if (audioEl.paused) {
pauseAllAudio();
audioEl.play();
playBtn.innerHTML = '&#9646;&#9646;';
} else {
audioEl.pause();
playBtn.innerHTML = '&#9654;';
}
});
audioEl.addEventListener('timeupdate', () => {
const pct = audioEl.duration ? (audioEl.currentTime / audioEl.duration) * 100 : 0;
progressFilled.style.width = `${pct}%`;
audioTime.textContent = `${fmtDuration(audioEl.currentTime)} / ${fmtDuration(audioEl.duration)}`;
});
audioEl.addEventListener('ended', () => {
playBtn.innerHTML = '&#9654;';
progressFilled.style.width = '0%';
});
progressBar.addEventListener('click', (e) => {
if (!audioEl.duration) return;
const rect = progressBar.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
audioEl.currentTime = ratio * audioEl.duration;
});
}
async function renderDetail() {
const rows = selectedQueryRows();
if (!rows.length) {
els.stickyInfo.innerHTML = '';
els.detail.innerHTML = '<div class="empty">选择一个样本查看详情</div>';
return;
}
......@@ -800,8 +962,11 @@
const selectedReview = candidate ? getReview(candidate) : {};
const conflict = candidate ? isConflict(candidate) : false;
const l1 = candidate ? l1Match(candidate) : false;
const queryAudioUrl = query.audio_url || '';
const candidateAudioUrl = candidate?.candidate_audio_url || '';
els.detail.innerHTML = `
// Sticky area: 新入库歌词 + 召回候选(各含音频播放器)
els.stickyInfo.innerHTML = `
<div class="detail-grid">
<div class="section">
<div class="section-head">
......@@ -815,6 +980,7 @@
<div>作曲</div><div>${esc(query.query_composer || '-')}</div>
<div>歌词</div><div class="path">${fmtPath(query.query_lyrics_path)}</div>
</div>
${audioPlayerHtml('queryAudio', queryAudioUrl)}
</div>
</div>
<div class="section">
......@@ -831,10 +997,13 @@
<div>L1</div><div>${l1 ? '<span class="pill l1">元数据命中</span>' : '<span class="pill">未命中</span>'} ${conflict ? '<span class="pill conflict">L1/L2 冲突</span>' : ''}</div>
<div>歌词</div><div class="path">${fmtPath(candidatePath)}</div>
</div>
${audioPlayerHtml('candAudio', candidateAudioUrl)}
</div>
</div>
</div>
</div>`;
// Scrollable area: 人工标注 + Top5候选 + 歌词对比
els.detail.innerHTML = `
<div class="section">
<div class="section-head">
<h2 class="section-title">人工标注</h2>
......@@ -881,6 +1050,9 @@
</div>
</div>`;
bindAudioPlayer('queryAudio');
bindAudioPlayer('candAudio');
for (const card of els.detail.querySelectorAll('.candidate')) {
card.addEventListener('click', () => {
state.candidateId = card.dataset.candidateId;
......
......@@ -163,6 +163,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
"reviewed_at": str(row.get("reviewed_at") or ""),
"l1_metadata_match": "1" if row.get("l1_matched_id") else "0",
"l1_l2_conflict": "0",
"audio_url": str(row.get("audio_url") or ""),
}
......@@ -176,8 +177,21 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int, de
# hits 模式下只展示有命中的记录(merge/review)
if mode == "hits":
where_clauses.append("dedup_action IN ('merge', 'review')")
# 决策筛选:new / merge / review / skip
if decision and decision in ('new', 'merge', 'review', 'skip'):
# 决策筛选:l1_hit / l2_hit / conflict / new / skip
if decision == 'l1_hit':
where_clauses.append("l1_matched_id IS NOT NULL")
elif decision == 'l2_duplicate':
where_clauses.append("dedup_action = 'merge'")
elif decision == 'l2_review':
where_clauses.append("dedup_action = 'review'")
elif decision == 'conflict':
where_clauses.append("(l1_matched_id IS NOT NULL AND dedup_action = 'new') OR (l1_matched_id IS NULL AND dedup_action IN ('merge', 'review'))")
elif decision == 'new':
where_clauses.append("dedup_action = 'new' AND l1_matched_id IS NULL")
elif decision == 'skip':
where_clauses.append("dedup_action = 'skip'")
# 兼容旧的 dedup_action 筛选
elif decision and decision in ('merge', 'review'):
where_clauses.append("dedup_action = %s")
params.append(decision)
where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
......@@ -242,14 +256,14 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
if matched_song_id:
# 先尝试目标表查找(matched_id 是目标库 ID 的情况)
cursor.execute(
f"SELECT name, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME} WHERE id = %s",
f"SELECT name, lyricist, composer, lyrics_url, audio_url FROM {TARGET_TABLE_NAME} WHERE id = %s",
(matched_song_id,),
)
matched_row = cursor.fetchone()
# 目标表找不到时,回退到暂存表查找(同批次内匹配,matched_id 是源库 source_song_id)
if not matched_row:
cursor.execute(
f"SELECT name, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME_TMP}"
f"SELECT name, lyricist, composer, lyrics_url, audio_url FROM {TARGET_TABLE_NAME_TMP}"
f" WHERE source_song_id = %s ORDER BY staging_id DESC LIMIT 1",
(str(matched_song_id),),
)
......@@ -266,6 +280,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")):
cand_lyrics = f"_raw:{cand_lyrics}"
dashboard_row["candidate_lyrics_path"] = cand_lyrics
dashboard_row["candidate_audio_url"] = str(matched_row.get("audio_url") or "")
rows = [dashboard_row]
......@@ -295,7 +310,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
with _target_conn() as tconn, tconn.cursor() as tcursor:
# 目标表查找
tcursor.execute(
f"SELECT id, name, lyricist, composer, lyrics_url"
f"SELECT id, name, lyricist, composer, lyrics_url, audio_url"
f" FROM {TARGET_TABLE_NAME} WHERE id IN ({placeholders})",
recalled_ids_int,
)
......@@ -306,7 +321,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
if missing_ids:
ph2 = ",".join(["%s"] * len(missing_ids))
tcursor.execute(
f"SELECT source_song_id, name, lyricist, composer, lyrics_url"
f"SELECT source_song_id, name, lyricist, composer, lyrics_url, audio_url"
f" FROM {TARGET_TABLE_NAME_TMP}"
f" WHERE source_song_id IN ({ph2})"
f" ORDER BY staging_id DESC",
......@@ -336,11 +351,13 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")):
cand_lyrics = f"_raw:{cand_lyrics}"
cand_row["candidate_lyrics_path"] = cand_lyrics
cand_row["candidate_audio_url"] = str(target_info.get("audio_url") or "")
else:
cand_row["candidate_name"] = str(cand.get("name") or "")
cand_row["candidate_lyricist"] = str(cand.get("lyricist") or "")
cand_row["candidate_composer"] = str(cand.get("composer") or "")
cand_row["candidate_lyrics_path"] = ""
cand_row["candidate_audio_url"] = ""
cand_row["candidate_decision"] = str(cand.get("decision") or "new")
cand_row["candidate_confidence"] = str(cand.get("confidence") or "")
cand_row["candidate_jaccard"] = str(cand.get("jaccard") or "")
......@@ -642,8 +659,20 @@ def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, pa
if mode == "hits":
groups = [g for g in groups if g.get("has_hit")]
# 决策筛选
if decision and decision in ('new', 'merge', 'review', 'duplicate', 'skip'):
groups = [g for g in groups if g.get("decision") == decision or (decision == 'merge' and g.get("decision") == 'merge')]
if decision == 'l1_hit':
groups = [g for g in groups if g.get("has_l1_hint")]
elif decision == 'l2_duplicate':
groups = [g for g in groups if g.get("decision") == "merge"]
elif decision == 'l2_review':
groups = [g for g in groups if g.get("decision") == "review"]
elif decision == 'conflict':
groups = [g for g in groups if g.get("has_conflict")]
elif decision == 'new':
groups = [g for g in groups if g.get("decision") == "new" and not g.get("has_l1_hint")]
elif decision == 'skip':
groups = [g for g in groups if g.get("decision") == "skip"]
elif decision and decision in ('merge', 'review', 'duplicate'):
groups = [g for g in groups if g.get("decision") == decision]
filtered = [group for group in groups if _matches_term(group, term)]
start = max(0, (page - 1) * page_size)
end = start + page_size
......