Commit abc96763 abc96763f11168a33b45d55bd9184466995cd06d by 沈秋雨

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

- 在L2审核面板新增音频播放器组件,实现播放、进度条、时间显示等交互
- 音频播放器支持多个实例,点击播放按钮互斥播放
- 页面布局中拆分详情为粘性区域和可滚动区域,更好展示新入库歌词和召回候选音频
- 后端接口增强,返回查询和候选音频URL数据支持前端播放
- 决策过滤条件扩展,支持l1命中、l2重复、l2审核、冲突、新歌、跳过等更多分类
- 更新筛选逻辑和选项标签,提升用户筛选灵活性和准确性
1 parent ae633f56
...@@ -115,9 +115,100 @@ ...@@ -115,9 +115,100 @@
115 115
116 .content { 116 .content {
117 overflow: auto; 117 overflow: auto;
118 padding: 0;
119 }
120
121 .sticky-info {
122 position: sticky;
123 top: 0;
124 z-index: 3;
125 background: var(--bg);
126 padding: 14px 18px 0;
127 }
128
129 .scroll-detail {
118 padding: 14px 18px 24px; 130 padding: 14px 18px 24px;
119 } 131 }
120 132
133 .audio-player {
134 display: flex;
135 align-items: center;
136 gap: 10px;
137 padding: 8px 10px;
138 background: #fbfcfd;
139 border: 1px solid var(--line);
140 border-radius: 8px;
141 margin-top: 10px;
142 }
143
144 .audio-player .play-btn {
145 width: 30px;
146 height: 30px;
147 border-radius: 50%;
148 background: var(--accent);
149 color: #fff;
150 border: none;
151 font-size: 16px;
152 display: inline-flex;
153 align-items: center;
154 justify-content: center;
155 cursor: pointer;
156 flex: none;
157 padding: 0;
158 }
159
160 .audio-player .play-btn:hover:not(:disabled) {
161 opacity: 0.85;
162 }
163
164 .audio-player.disabled .play-btn {
165 background: #c8d0d9;
166 cursor: not-allowed;
167 }
168
169 .audio-player.disabled .audio-progress-bar {
170 background: #eef1f4;
171 }
172
173 .audio-player.disabled .audio-time {
174 color: #aab4bf;
175 }
176
177 .audio-progress-wrap {
178 flex: 1;
179 display: flex;
180 flex-direction: column;
181 gap: 2px;
182 min-width: 0;
183 }
184
185 .audio-progress-bar {
186 width: 100%;
187 height: 6px;
188 border-radius: 3px;
189 background: #e4e8ed;
190 cursor: pointer;
191 position: relative;
192 overflow: hidden;
193 }
194
195 .audio-progress-bar .filled {
196 height: 100%;
197 background: var(--accent);
198 border-radius: 3px;
199 width: 0%;
200 transition: width 0.15s linear;
201 }
202
203 .audio-time {
204 font-size: 12px;
205 color: var(--muted);
206 white-space: nowrap;
207 flex: none;
208 min-width: 80px;
209 text-align: right;
210 }
211
121 .section { 212 .section {
122 margin-bottom: 14px; 213 margin-bottom: 14px;
123 background: var(--panel); 214 background: var(--panel);
...@@ -447,6 +538,7 @@ ...@@ -447,6 +538,7 @@
447 main { grid-template-columns: 1fr; height: auto; } 538 main { grid-template-columns: 1fr; height: auto; }
448 aside { border-right: 0; border-bottom: 1px solid var(--line); max-height: 420px; } 539 aside { border-right: 0; border-bottom: 1px solid var(--line); max-height: 420px; }
449 .detail-grid, .lyrics-grid { grid-template-columns: 1fr; } 540 .detail-grid, .lyrics-grid { grid-template-columns: 1fr; }
541 .sticky-info { position: relative; padding: 10px; }
450 header { height: auto; min-height: 56px; flex-wrap: wrap; padding: 10px; } 542 header { height: auto; min-height: 56px; flex-wrap: wrap; padding: 10px; }
451 .toolbar { flex-wrap: wrap; } 543 .toolbar { flex-wrap: wrap; }
452 } 544 }
...@@ -460,13 +552,15 @@ ...@@ -460,13 +552,15 @@
460 <select id="runSelect"></select> 552 <select id="runSelect"></select>
461 <label id="topKLabel" for="topKSelect">topK</label> 553 <label id="topKLabel" for="topKSelect">topK</label>
462 <select id="topKSelect"></select> 554 <select id="topKSelect"></select>
463 <label for="decisionFilter">决策</label> 555 <label for="decisionFilter">类型</label>
464 <select id="decisionFilter"> 556 <select id="decisionFilter">
465 <option value="">全部</option> 557 <option value="">全部</option>
466 <option value="new">new</option> 558 <option value="l1_hit">L1 命中</option>
467 <option value="merge">merge</option> 559 <option value="l2_duplicate">L2 重复</option>
468 <option value="review">review</option> 560 <option value="l2_review">L2 审核</option>
469 <option value="skip">skip</option> 561 <option value="conflict">L1/L2 冲突</option>
562 <option value="new">新歌</option>
563 <option value="skip">已跳过</option>
470 </select> 564 </select>
471 <input id="searchInput" type="search" placeholder="搜索歌名 / ID"> 565 <input id="searchInput" type="search" placeholder="搜索歌名 / ID">
472 <label for="pageSizeSelect">每页</label> 566 <label for="pageSizeSelect">每页</label>
...@@ -511,7 +605,8 @@ ...@@ -511,7 +605,8 @@
511 </aside> 605 </aside>
512 606
513 <section class="content"> 607 <section class="content">
514 <div id="detail"></div> 608 <div id="stickyInfo" class="sticky-info"></div>
609 <div id="scrollDetail" class="scroll-detail"></div>
515 </section> 610 </section>
516 </main> 611 </main>
517 612
...@@ -550,7 +645,8 @@ ...@@ -550,7 +645,8 @@
550 metrics: document.getElementById('metrics'), 645 metrics: document.getElementById('metrics'),
551 sampleCount: document.getElementById('sampleCount'), 646 sampleCount: document.getElementById('sampleCount'),
552 queryList: document.getElementById('queryList'), 647 queryList: document.getElementById('queryList'),
553 detail: document.getElementById('detail') 648 detail: document.getElementById('scrollDetail'),
649 stickyInfo: document.getElementById('stickyInfo')
554 }; 650 };
555 651
556 function esc(value) { 652 function esc(value) {
...@@ -680,6 +776,7 @@ ...@@ -680,6 +776,7 @@
680 els.queryList.innerHTML = '<div class="empty">没有样本</div>'; 776 els.queryList.innerHTML = '<div class="empty">没有样本</div>';
681 state.queryId = ''; 777 state.queryId = '';
682 state.queryRows = []; 778 state.queryRows = [];
779 els.stickyInfo.innerHTML = '';
683 renderDetail(); 780 renderDetail();
684 return; 781 return;
685 } 782 }
...@@ -781,9 +878,74 @@ ...@@ -781,9 +878,74 @@
781 deleted: '已删除', 878 deleted: '已删除',
782 }; 879 };
783 880
881 function fmtDuration(sec) {
882 if (!sec || !isFinite(sec)) return '0:00';
883 const m = Math.floor(sec / 60);
884 const s = Math.floor(sec % 60);
885 return `${m}:${s.toString().padStart(2, '0')}`;
886 }
887
888 function audioPlayerHtml(id, url) {
889 const disabled = !url;
890 return `
891 <div class="audio-player${disabled ? ' disabled' : ''}" id="${id}Player">
892 <button class="play-btn" id="${id}PlayBtn" ${disabled ? 'disabled' : ''}>&#9654;</button>
893 <div class="audio-progress-wrap">
894 <div class="audio-progress-bar" id="${id}ProgressBar"><div class="filled" id="${id}ProgressFilled"></div></div>
895 </div>
896 <span class="audio-time" id="${id}Time">${disabled ? '无音频' : '0:00 / 0:00'}</span>
897 ${url ? `<audio id="${id}El" preload="metadata" src="${esc(url)}"></audio>` : ''}
898 </div>`;
899 }
900
901 function bindAudioPlayer(id) {
902 const audioEl = document.getElementById(`${id}El`);
903 const playBtn = document.getElementById(`${id}PlayBtn`);
904 const progressBar = document.getElementById(`${id}ProgressBar`);
905 const progressFilled = document.getElementById(`${id}ProgressFilled`);
906 const audioTime = document.getElementById(`${id}Time`);
907 if (!audioEl || !playBtn) return;
908 const pauseAllAudio = () => {
909 for (const el of document.querySelectorAll('.audio-player audio')) {
910 el.pause();
911 const btn = el.closest('.audio-player').querySelector('.play-btn');
912 if (btn) btn.innerHTML = '&#9654;';
913 }
914 };
915 audioEl.addEventListener('loadedmetadata', () => {
916 audioTime.textContent = `0:00 / ${fmtDuration(audioEl.duration)}`;
917 });
918 playBtn.addEventListener('click', () => {
919 if (audioEl.paused) {
920 pauseAllAudio();
921 audioEl.play();
922 playBtn.innerHTML = '&#9646;&#9646;';
923 } else {
924 audioEl.pause();
925 playBtn.innerHTML = '&#9654;';
926 }
927 });
928 audioEl.addEventListener('timeupdate', () => {
929 const pct = audioEl.duration ? (audioEl.currentTime / audioEl.duration) * 100 : 0;
930 progressFilled.style.width = `${pct}%`;
931 audioTime.textContent = `${fmtDuration(audioEl.currentTime)} / ${fmtDuration(audioEl.duration)}`;
932 });
933 audioEl.addEventListener('ended', () => {
934 playBtn.innerHTML = '&#9654;';
935 progressFilled.style.width = '0%';
936 });
937 progressBar.addEventListener('click', (e) => {
938 if (!audioEl.duration) return;
939 const rect = progressBar.getBoundingClientRect();
940 const ratio = (e.clientX - rect.left) / rect.width;
941 audioEl.currentTime = ratio * audioEl.duration;
942 });
943 }
944
784 async function renderDetail() { 945 async function renderDetail() {
785 const rows = selectedQueryRows(); 946 const rows = selectedQueryRows();
786 if (!rows.length) { 947 if (!rows.length) {
948 els.stickyInfo.innerHTML = '';
787 els.detail.innerHTML = '<div class="empty">选择一个样本查看详情</div>'; 949 els.detail.innerHTML = '<div class="empty">选择一个样本查看详情</div>';
788 return; 950 return;
789 } 951 }
...@@ -800,8 +962,11 @@ ...@@ -800,8 +962,11 @@
800 const selectedReview = candidate ? getReview(candidate) : {}; 962 const selectedReview = candidate ? getReview(candidate) : {};
801 const conflict = candidate ? isConflict(candidate) : false; 963 const conflict = candidate ? isConflict(candidate) : false;
802 const l1 = candidate ? l1Match(candidate) : false; 964 const l1 = candidate ? l1Match(candidate) : false;
965 const queryAudioUrl = query.audio_url || '';
966 const candidateAudioUrl = candidate?.candidate_audio_url || '';
803 967
804 els.detail.innerHTML = ` 968 // Sticky area: 新入库歌词 + 召回候选(各含音频播放器)
969 els.stickyInfo.innerHTML = `
805 <div class="detail-grid"> 970 <div class="detail-grid">
806 <div class="section"> 971 <div class="section">
807 <div class="section-head"> 972 <div class="section-head">
...@@ -815,6 +980,7 @@ ...@@ -815,6 +980,7 @@
815 <div>作曲</div><div>${esc(query.query_composer || '-')}</div> 980 <div>作曲</div><div>${esc(query.query_composer || '-')}</div>
816 <div>歌词</div><div class="path">${fmtPath(query.query_lyrics_path)}</div> 981 <div>歌词</div><div class="path">${fmtPath(query.query_lyrics_path)}</div>
817 </div> 982 </div>
983 ${audioPlayerHtml('queryAudio', queryAudioUrl)}
818 </div> 984 </div>
819 </div> 985 </div>
820 <div class="section"> 986 <div class="section">
...@@ -831,10 +997,13 @@ ...@@ -831,10 +997,13 @@
831 <div>L1</div><div>${l1 ? '<span class="pill l1">元数据命中</span>' : '<span class="pill">未命中</span>'} ${conflict ? '<span class="pill conflict">L1/L2 冲突</span>' : ''}</div> 997 <div>L1</div><div>${l1 ? '<span class="pill l1">元数据命中</span>' : '<span class="pill">未命中</span>'} ${conflict ? '<span class="pill conflict">L1/L2 冲突</span>' : ''}</div>
832 <div>歌词</div><div class="path">${fmtPath(candidatePath)}</div> 998 <div>歌词</div><div class="path">${fmtPath(candidatePath)}</div>
833 </div> 999 </div>
1000 ${audioPlayerHtml('candAudio', candidateAudioUrl)}
834 </div> 1001 </div>
835 </div> 1002 </div>
836 </div> 1003 </div>`;
837 1004
1005 // Scrollable area: 人工标注 + Top5候选 + 歌词对比
1006 els.detail.innerHTML = `
838 <div class="section"> 1007 <div class="section">
839 <div class="section-head"> 1008 <div class="section-head">
840 <h2 class="section-title">人工标注</h2> 1009 <h2 class="section-title">人工标注</h2>
...@@ -881,6 +1050,9 @@ ...@@ -881,6 +1050,9 @@
881 </div> 1050 </div>
882 </div>`; 1051 </div>`;
883 1052
1053 bindAudioPlayer('queryAudio');
1054 bindAudioPlayer('candAudio');
1055
884 for (const card of els.detail.querySelectorAll('.candidate')) { 1056 for (const card of els.detail.querySelectorAll('.candidate')) {
885 card.addEventListener('click', () => { 1057 card.addEventListener('click', () => {
886 state.candidateId = card.dataset.candidateId; 1058 state.candidateId = card.dataset.candidateId;
......
...@@ -163,6 +163,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]: ...@@ -163,6 +163,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
163 "reviewed_at": str(row.get("reviewed_at") or ""), 163 "reviewed_at": str(row.get("reviewed_at") or ""),
164 "l1_metadata_match": "1" if row.get("l1_matched_id") else "0", 164 "l1_metadata_match": "1" if row.get("l1_matched_id") else "0",
165 "l1_l2_conflict": "0", 165 "l1_l2_conflict": "0",
166 "audio_url": str(row.get("audio_url") or ""),
166 } 167 }
167 168
168 169
...@@ -176,8 +177,21 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int, de ...@@ -176,8 +177,21 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int, de
176 # hits 模式下只展示有命中的记录(merge/review) 177 # hits 模式下只展示有命中的记录(merge/review)
177 if mode == "hits": 178 if mode == "hits":
178 where_clauses.append("dedup_action IN ('merge', 'review')") 179 where_clauses.append("dedup_action IN ('merge', 'review')")
179 # 决策筛选:new / merge / review / skip 180 # 决策筛选:l1_hit / l2_hit / conflict / new / skip
180 if decision and decision in ('new', 'merge', 'review', 'skip'): 181 if decision == 'l1_hit':
182 where_clauses.append("l1_matched_id IS NOT NULL")
183 elif decision == 'l2_duplicate':
184 where_clauses.append("dedup_action = 'merge'")
185 elif decision == 'l2_review':
186 where_clauses.append("dedup_action = 'review'")
187 elif decision == 'conflict':
188 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'))")
189 elif decision == 'new':
190 where_clauses.append("dedup_action = 'new' AND l1_matched_id IS NULL")
191 elif decision == 'skip':
192 where_clauses.append("dedup_action = 'skip'")
193 # 兼容旧的 dedup_action 筛选
194 elif decision and decision in ('merge', 'review'):
181 where_clauses.append("dedup_action = %s") 195 where_clauses.append("dedup_action = %s")
182 params.append(decision) 196 params.append(decision)
183 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" 197 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
...@@ -242,14 +256,14 @@ def _staging_query_rows(query_id: str) -> dict[str, object]: ...@@ -242,14 +256,14 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
242 if matched_song_id: 256 if matched_song_id:
243 # 先尝试目标表查找(matched_id 是目标库 ID 的情况) 257 # 先尝试目标表查找(matched_id 是目标库 ID 的情况)
244 cursor.execute( 258 cursor.execute(
245 f"SELECT name, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME} WHERE id = %s", 259 f"SELECT name, lyricist, composer, lyrics_url, audio_url FROM {TARGET_TABLE_NAME} WHERE id = %s",
246 (matched_song_id,), 260 (matched_song_id,),
247 ) 261 )
248 matched_row = cursor.fetchone() 262 matched_row = cursor.fetchone()
249 # 目标表找不到时,回退到暂存表查找(同批次内匹配,matched_id 是源库 source_song_id) 263 # 目标表找不到时,回退到暂存表查找(同批次内匹配,matched_id 是源库 source_song_id)
250 if not matched_row: 264 if not matched_row:
251 cursor.execute( 265 cursor.execute(
252 f"SELECT name, lyricist, composer, lyrics_url FROM {TARGET_TABLE_NAME_TMP}" 266 f"SELECT name, lyricist, composer, lyrics_url, audio_url FROM {TARGET_TABLE_NAME_TMP}"
253 f" WHERE source_song_id = %s ORDER BY staging_id DESC LIMIT 1", 267 f" WHERE source_song_id = %s ORDER BY staging_id DESC LIMIT 1",
254 (str(matched_song_id),), 268 (str(matched_song_id),),
255 ) 269 )
...@@ -266,6 +280,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]: ...@@ -266,6 +280,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
266 if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")): 280 if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")):
267 cand_lyrics = f"_raw:{cand_lyrics}" 281 cand_lyrics = f"_raw:{cand_lyrics}"
268 dashboard_row["candidate_lyrics_path"] = cand_lyrics 282 dashboard_row["candidate_lyrics_path"] = cand_lyrics
283 dashboard_row["candidate_audio_url"] = str(matched_row.get("audio_url") or "")
269 284
270 rows = [dashboard_row] 285 rows = [dashboard_row]
271 286
...@@ -295,7 +310,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]: ...@@ -295,7 +310,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
295 with _target_conn() as tconn, tconn.cursor() as tcursor: 310 with _target_conn() as tconn, tconn.cursor() as tcursor:
296 # 目标表查找 311 # 目标表查找
297 tcursor.execute( 312 tcursor.execute(
298 f"SELECT id, name, lyricist, composer, lyrics_url" 313 f"SELECT id, name, lyricist, composer, lyrics_url, audio_url"
299 f" FROM {TARGET_TABLE_NAME} WHERE id IN ({placeholders})", 314 f" FROM {TARGET_TABLE_NAME} WHERE id IN ({placeholders})",
300 recalled_ids_int, 315 recalled_ids_int,
301 ) 316 )
...@@ -306,7 +321,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]: ...@@ -306,7 +321,7 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
306 if missing_ids: 321 if missing_ids:
307 ph2 = ",".join(["%s"] * len(missing_ids)) 322 ph2 = ",".join(["%s"] * len(missing_ids))
308 tcursor.execute( 323 tcursor.execute(
309 f"SELECT source_song_id, name, lyricist, composer, lyrics_url" 324 f"SELECT source_song_id, name, lyricist, composer, lyrics_url, audio_url"
310 f" FROM {TARGET_TABLE_NAME_TMP}" 325 f" FROM {TARGET_TABLE_NAME_TMP}"
311 f" WHERE source_song_id IN ({ph2})" 326 f" WHERE source_song_id IN ({ph2})"
312 f" ORDER BY staging_id DESC", 327 f" ORDER BY staging_id DESC",
...@@ -336,11 +351,13 @@ def _staging_query_rows(query_id: str) -> dict[str, object]: ...@@ -336,11 +351,13 @@ def _staging_query_rows(query_id: str) -> dict[str, object]:
336 if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")): 351 if cand_lyrics and not cand_lyrics.startswith(("http://", "https://", "/")):
337 cand_lyrics = f"_raw:{cand_lyrics}" 352 cand_lyrics = f"_raw:{cand_lyrics}"
338 cand_row["candidate_lyrics_path"] = cand_lyrics 353 cand_row["candidate_lyrics_path"] = cand_lyrics
354 cand_row["candidate_audio_url"] = str(target_info.get("audio_url") or "")
339 else: 355 else:
340 cand_row["candidate_name"] = str(cand.get("name") or "") 356 cand_row["candidate_name"] = str(cand.get("name") or "")
341 cand_row["candidate_lyricist"] = str(cand.get("lyricist") or "") 357 cand_row["candidate_lyricist"] = str(cand.get("lyricist") or "")
342 cand_row["candidate_composer"] = str(cand.get("composer") or "") 358 cand_row["candidate_composer"] = str(cand.get("composer") or "")
343 cand_row["candidate_lyrics_path"] = "" 359 cand_row["candidate_lyrics_path"] = ""
360 cand_row["candidate_audio_url"] = ""
344 cand_row["candidate_decision"] = str(cand.get("decision") or "new") 361 cand_row["candidate_decision"] = str(cand.get("decision") or "new")
345 cand_row["candidate_confidence"] = str(cand.get("confidence") or "") 362 cand_row["candidate_confidence"] = str(cand.get("confidence") or "")
346 cand_row["candidate_jaccard"] = str(cand.get("jaccard") or "") 363 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 ...@@ -642,8 +659,20 @@ def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, pa
642 if mode == "hits": 659 if mode == "hits":
643 groups = [g for g in groups if g.get("has_hit")] 660 groups = [g for g in groups if g.get("has_hit")]
644 # 决策筛选 661 # 决策筛选
645 if decision and decision in ('new', 'merge', 'review', 'duplicate', 'skip'): 662 if decision == 'l1_hit':
646 groups = [g for g in groups if g.get("decision") == decision or (decision == 'merge' and g.get("decision") == 'merge')] 663 groups = [g for g in groups if g.get("has_l1_hint")]
664 elif decision == 'l2_duplicate':
665 groups = [g for g in groups if g.get("decision") == "merge"]
666 elif decision == 'l2_review':
667 groups = [g for g in groups if g.get("decision") == "review"]
668 elif decision == 'conflict':
669 groups = [g for g in groups if g.get("has_conflict")]
670 elif decision == 'new':
671 groups = [g for g in groups if g.get("decision") == "new" and not g.get("has_l1_hint")]
672 elif decision == 'skip':
673 groups = [g for g in groups if g.get("decision") == "skip"]
674 elif decision and decision in ('merge', 'review', 'duplicate'):
675 groups = [g for g in groups if g.get("decision") == decision]
647 filtered = [group for group in groups if _matches_term(group, term)] 676 filtered = [group for group in groups if _matches_term(group, term)]
648 start = max(0, (page - 1) * page_size) 677 start = max(0, (page - 1) * page_size)
649 end = start + page_size 678 end = start + page_size
......