From 0d7daa16bb1fe1e8d05ca9712100900a3ea26ffb Mon Sep 17 00:00:00 2001 From: liumangmang Date: Mon, 11 May 2026 15:32:29 +0800 Subject: [PATCH] Improve exception workflow layout and match feedback --- frontend/src/components/AppLayout.jsx | 203 ++++++--------- .../src/components/MissingTagsInlinePanel.jsx | 76 ++++-- .../components/exceptions/ExceptionWizard.jsx | 101 ++++++-- .../exceptions/MatchRunFeedback.jsx | 116 +++++++++ .../components/exceptions/RepairTaskPanel.jsx | 74 +++++- .../exceptions/steps/StepListen.jsx | 238 ++++++++++++++---- .../components/exceptions/steps/StepMatch.jsx | 79 +++--- frontend/src/hooks/useRepairTask.js | 11 +- frontend/src/pages/ExceptionPage.jsx | 54 +++- frontend/src/pages/LibraryPage.jsx | 8 +- frontend/src/utils/exceptions.js | 214 +++++++++++++++- 11 files changed, 906 insertions(+), 268 deletions(-) create mode 100644 frontend/src/components/exceptions/MatchRunFeedback.jsx diff --git a/frontend/src/components/AppLayout.jsx b/frontend/src/components/AppLayout.jsx index 4635d72..af49197 100644 --- a/frontend/src/components/AppLayout.jsx +++ b/frontend/src/components/AppLayout.jsx @@ -1,143 +1,102 @@ +import { Link, Outlet, useLocation } from 'react-router-dom'; import { - Activity, - AlertTriangle, + Clock, Database, - History, - LayoutDashboard, - RefreshCw, + Home, Settings, - Wifi + TriangleAlert, } from 'lucide-react'; -import { NavLink, Outlet, useLocation } from 'react-router-dom'; -const NAV_ITEMS = [ - { to: '/workbench', label: '工作台', icon: LayoutDashboard }, - { to: '/library', label: '音乐库', icon: Database }, - { to: '/exceptions', label: '异常中心', icon: AlertTriangle }, - { to: '/history', label: '任务历史', icon: History }, - { to: '/settings', label: '系统配置', icon: Settings } +// 导航项定义 +const navItems = [ + { path: '/workbench', label: '工作台', icon: Home }, + { path: '/library', label: '媒体库', icon: Database }, + { path: '/exceptions', label: '异常', icon: TriangleAlert }, + { path: '/history', label: '历史记录', icon: Clock }, + { path: '/settings', label: '设置', icon: Settings }, ]; -const PAGE_TITLES = { - '/workbench': '工作台', - '/library': '音乐库', - '/exceptions': '异常中心', - '/history': '任务历史', - '/settings': '系统配置' -}; - export default function AppLayout({ connState, taskState }) { const location = useLocation(); - const pageTitle = PAGE_TITLES[location.pathname] || '工作台'; - const isWorkbenchPage = location.pathname === '/workbench'; return ( -
-
-
- - 音流工坊 -
-
- 主菜单 -
- -
- Navidrome Auto-Ingest Engine v1.2.0 -
+
+ {/* 动态粒子背景(可选简单渐变,更高性能) */} +
+
+
-
-
-

{pageTitle}

-
- {isWorkbenchPage && ( -
- {connState === 'connected' ? ( - <> - - - - - - - 实时连接中 (WS) - - - ) : ( - <> - - 轮询兜底中 - - )} -
- )} -
- 系统状态: - - {taskState === 'unconfigured' - ? '未配置' - : taskState === 'ready' - ? '已配置,待机中' - : taskState === 'running' - ? '任务执行中' - : taskState === 'failed' - ? '任务失败' - : '批次完成'} + {/* 顶部玻璃导航栏 */} +
+
+ {/* Logo + 状态指示器 */} +
+ +
+

MusicWorkshop

+ +
+
+
+ + {connState === 'connected' ? '实时连接' : + connState === 'connecting' ? '连接中...' : + connState === 'reconnecting' ? '重连中...' : + connState === 'closed' ? '未连接' : '空闲'}
-
-
+ {/* 导航链接 */} + + + {/* 移动端菜单按钮 (简化,未完整实现) */} + +
+
+ + {/* 主内容区域 */} +
+
-
-
+
+
); } - -function NavButton({ to, icon: Icon, label }) { - return ( - - `flex w-full items-center space-x-3 rounded-lg px-3 py-3 transition-colors ${ - isActive - ? 'bg-slate-800 text-white' - : 'hover:bg-slate-800/50 hover:text-slate-100' - }` - } - > - - {label} - - ); -} diff --git a/frontend/src/components/MissingTagsInlinePanel.jsx b/frontend/src/components/MissingTagsInlinePanel.jsx index d2bef0d..29fc047 100644 --- a/frontend/src/components/MissingTagsInlinePanel.jsx +++ b/frontend/src/components/MissingTagsInlinePanel.jsx @@ -24,6 +24,8 @@ import { fetchRepairTask, fetchRepairTaskLogs } from '../api/repairs'; +import MatchRunFeedback from './exceptions/MatchRunFeedback'; +import { candidateSignature } from '../utils/exceptions'; // ── Constants (mirrored from ExceptionPage.jsx) ────────────────────────────── @@ -109,7 +111,9 @@ function compareTimestampDesc(a, b) { function normalizeActionParams(action, params) { if (action === 'retry_match') { - return { provider_mode: params.provider_mode || 'all', providers: params.providers || [] }; + const providerMode = params.provider_mode || 'all'; + const selectedMode = PROVIDER_MODES.find((mode) => mode.id === providerMode); + return { provider_mode: providerMode, providers: selectedMode?.providers || [] }; } if (action === 'save_and_organize' || action === 'edit_metadata') { return { metadata_patch: { ...(params.metadata_patch || {}) } }; @@ -208,7 +212,6 @@ export default function MissingTagsInlinePanel({ // Metadata editor state const [metadataPatch, setMetadataPatch] = useState({}); const [providerMode, setProviderMode] = useState('all'); - const [providers, setProviders] = useState([]); // Combined state using useReducer const [previewState, setPreviewState] = useReducer( @@ -306,6 +309,31 @@ export default function MissingTagsInlinePanel({ return () => controller.abort(); }, [selectedId]); + // ── Re-fetch current selected detail (用于任务完成后刷新) ──────────────── + const refreshSelectedDetail = useCallback(async ({ clearTransient = false } = {}) => { + if (!selectedId) return; + setIsDetailLoading(true); + setDetailError(''); + try { + const payload = await fetchExceptionItem(selectedId); + setDetail(payload); + const md = payload.effective_metadata || payload.matched_metadata_json || payload.original_tags_json || {}; + setMetadataPatch({ + title: md.title || '', artist: md.artist || '', album: md.album || '', + album_artist: md.album_artist || '', track_number: md.track_number ?? null, + disc_number: md.disc_number ?? null, year: md.year ?? null, lyrics: md.lyrics || '' + }); + if (clearTransient) { + setPreviewState({ type: 'CLEAR' }); + setExecutionState({ type: 'CLEAR' }); + } + } catch (err) { + setDetailError(err.message || '详情加载失败'); + } finally { + setIsDetailLoading(false); + } + }, [selectedId]); + // ── Initial load ───────────────────────────────────────────────────────── useEffect(() => { loadQueue(false); }, []); useEffect(() => { @@ -375,6 +403,7 @@ export default function MissingTagsInlinePanel({ if (isTerminalRepairStatus(repairTask.status) && !completedRefreshRef.current.has(repairTask.task_id)) { completedRefreshRef.current.add(repairTask.task_id); loadQueue(true); + refreshSelectedDetail({ clearTransient: false }); } }, [repairTask]); @@ -389,8 +418,7 @@ export default function MissingTagsInlinePanel({ if (!selectedId || !action) return; const params = normalizeActionParams(action, { metadata_patch: metadataPatch, - provider_mode: providerMode, - providers + provider_mode: providerMode }); setPreviewState({ type: 'START', action }); try { @@ -399,7 +427,7 @@ export default function MissingTagsInlinePanel({ } catch (err) { setPreviewState({ type: 'ERROR', error: err.message || '预览生成失败' }); } - }, [selectedId, metadataPatch, providerMode, providers]); + }, [selectedId, metadataPatch, providerMode]); // ── Execute handler ────────────────────────────────────────────────────── const handleExecute = useCallback(async (action) => { @@ -408,13 +436,21 @@ export default function MissingTagsInlinePanel({ if (!window.confirm('将永久删除选中的文件,且无法恢复。是否继续?')) return; if (!window.confirm('请再次确认:这会真实删除文件,不是忽略。是否执行删除?')) return; } - setExecutionState({ type: 'SUBMIT', payload: { exceptionId: selectedId, action, submittedAt: new Date().toISOString() } }); + const params = normalizeActionParams(action, { + metadata_patch: metadataPatch, + provider_mode: providerMode + }); + setExecutionState({ type: 'SUBMIT', payload: { + exceptionId: selectedId, + action, + submittedAt: new Date().toISOString(), + requestedProviderMode: providerMode, + requestedProviders: params.providers, + submittedParams: params, + beforeCandidateSignature: action === 'retry_match' ? candidateSignature(detail?.match_candidates_json || []) : '' + } }); try { - const params = normalizeActionParams(action, { - metadata_patch: metadataPatch, - provider_mode: providerMode, - providers - }); + // params 已在上方计算 const payload = await executeExceptionAction({ exception_ids: [selectedId], action, params }); setExecutionState({ type: 'ACCEPT', repairTaskId: payload.repair_task_id }); const tp = await fetchRepairTask(payload.repair_task_id); @@ -424,13 +460,13 @@ export default function MissingTagsInlinePanel({ } catch (err) { setExecutionState({ type: 'FAIL', error: err.message || '执行失败' }); } - }, [selectedId, metadataPatch, providerMode, providers]); + }, [selectedId, metadataPatch, providerMode]); // ── Smart ingest: preview then execute ──────────────────────────────────── const handleIngest = useCallback(async () => { if (!canIngest) return; try { - const params = normalizeActionParams('save_and_organize', { metadata_patch: metadataPatch, provider_mode: providerMode, providers }); + const params = normalizeActionParams('save_and_organize', { metadata_patch: metadataPatch, provider_mode: providerMode }); const previewP = await previewExceptionAction({ exception_ids: [selectedId], action: 'save_and_organize', params }); setPreviewState({ type: 'SUCCESS', payload: previewP, action: 'save_and_organize' }); // Directly execute after preview @@ -440,7 +476,7 @@ export default function MissingTagsInlinePanel({ // Also clear any stale execution state setExecutionState({ type: 'CLEAR' }); } - }, [canIngest, metadataPatch, providerMode, providers, selectedId, handleExecute]); + }, [canIngest, metadataPatch, providerMode, selectedId, handleExecute]); // ── Audio player state ─────────────────────────────────────────────────── const audioRef = useRef(null); @@ -612,7 +648,7 @@ export default function MissingTagsInlinePanel({ {PROVIDER_MODES.map((mode) => { const active = providerMode === mode.id; return ( - @@ -634,7 +670,12 @@ export default function MissingTagsInlinePanel({
) : null} {executionState?.action === 'retry_match' ? ( -
{renderInlineBadge(executionState.status)}
+ ) : null}
@@ -801,7 +842,8 @@ export default function MissingTagsInlinePanel({

执行操作

入库前请确认右侧预览结果。忽略只改状态,删除会真实删除文件。

- {renderInlineBadge(executionState?.status)} + {/* retry_match 已用 MatchRunFeedback 展示,不重复显示 */} + {executionState?.action !== 'retry_match' && renderInlineBadge(executionState?.status)}
{/* Primary: Ingest */} diff --git a/frontend/src/components/exceptions/ExceptionWizard.jsx b/frontend/src/components/exceptions/ExceptionWizard.jsx index c01a096..4026bce 100644 --- a/frontend/src/components/exceptions/ExceptionWizard.jsx +++ b/frontend/src/components/exceptions/ExceptionWizard.jsx @@ -1,4 +1,5 @@ // frontend/src/components/exceptions/ExceptionWizard.jsx +import { useEffect } from 'react'; import { Music2, Wrench, Check } from 'lucide-react'; import { WIZARD_STEPS, actionButtonClass, getMissingRequiredFields } from '../../utils/exceptions'; import ExceptionStatsBar from './ExceptionStatsBar'; @@ -28,6 +29,49 @@ export default function ExceptionWizard({ const missingFields = getMissingRequiredFields(draft); const canIngest = missingFields.length === 0 && (detailRecord?.available_actions || []).includes('save_and_organize'); + // 当前异常类型 — 用于统计卡高亮 + const activeExceptionType = detailRecord?.exception_type || null; + + // 键盘快捷键 + useEffect(() => { + if (typeof window === 'undefined') return; + const handler = (e) => { + // 不干扰可交互元素 + const tag = e.target?.tagName?.toLowerCase(); + if ( + tag === 'input' || tag === 'textarea' || tag === 'select' || + tag === 'button' || tag === 'a' || + e.target?.isContentEditable + ) return; + + if (e.key === 'ArrowUp') { + e.preventDefault(); + const idx = metadataQueue.findIndex((i) => i.exception_id === selectedExceptionId); + if (idx > 0) { + const prev = metadataQueue[idx - 1]; + onSelectItem(prev.exception_id); + onSetWizardStep('listen'); + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + const idx = metadataQueue.findIndex((i) => i.exception_id === selectedExceptionId); + if (idx >= 0 && idx < metadataQueue.length - 1) { + const next = metadataQueue[idx + 1]; + onSelectItem(next.exception_id); + onSetWizardStep('listen'); + } + } else if (e.key === 'Enter') { + // Enter: 在 listen 步骤时跳到 match + if (wizardStep === 'listen') { + e.preventDefault(); + onSetWizardStep('match'); + } + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [metadataQueue, selectedExceptionId, wizardStep, onSelectItem, onSetWizardStep]); + const renderStep = () => { switch (wizardStep) { case 'select': @@ -41,7 +85,7 @@ export default function ExceptionWizard({ /> ); case 'listen': - return ; + return ; case 'match': return ( ); @@ -79,7 +124,7 @@ export default function ExceptionWizard({ }; return ( -
+
@@ -97,18 +142,24 @@ export default function ExceptionWizard({ metadataTotal={metadataTotal} metadataQueueCounts={metadataQueueCounts} viewMode="wizard" + activeExceptionType={activeExceptionType} />
- +
+ {repairTask && ( + + )} + +
-
+

待处理队列

@@ -127,7 +178,7 @@ export default function ExceptionWizard({
-
+
{!detailRecord && !isDetailLoading && wizardStep !== 'select' ? (
@@ -136,18 +187,22 @@ export default function ExceptionWizard({ ) : detailError ? (
{detailError}
) : ( -
- {wizardStep !== 'select' && renderStep()} +
+ {wizardStep !== 'select' && ( + <> +
+ + 当前步骤:{WIZARD_STEPS.find((s) => s.id === wizardStep)?.label || wizardStep} + +
+ {renderStep()} + + )}
)}
- {repairTask && ( -
- -
- )}
); } @@ -167,21 +222,25 @@ function WizardProgressBar({ currentStep, onSelectStep }) { onClick={() => onSelectStep(step.id)} className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium transition ${ isCurrent - ? 'bg-indigo-500/20 border border-indigo-400/50 text-indigo-200' + ? 'bg-indigo-500/25 border-2 border-indigo-400/70 text-indigo-100 font-semibold shadow-[0_0_12px_rgba(99,102,241,0.2)]' : isCompleted - ? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-200' - : 'border border-slate-700 text-slate-500 hover:border-slate-600' + ? 'bg-emerald-500/12 border border-emerald-500/40 text-emerald-200' + : 'border border-slate-700/70 text-slate-500 hover:border-slate-500' }`} > {isCompleted ? ( - + ) : ( - {index + 1} + + {index + 1} + )} {step.label} {index < WIZARD_STEPS.length - 1 && ( -
+
)}
); diff --git a/frontend/src/components/exceptions/MatchRunFeedback.jsx b/frontend/src/components/exceptions/MatchRunFeedback.jsx new file mode 100644 index 0000000..5d03197 --- /dev/null +++ b/frontend/src/components/exceptions/MatchRunFeedback.jsx @@ -0,0 +1,116 @@ +import { + buildMatchRunExplanation, + candidateSignature, + deriveRepairOutcome, + providerModeLabel +} from '../../utils/exceptions'; + +const OUTCOME_COLORS = { + submitting: 'border-amber-500/30 bg-amber-500/10 text-amber-200', + accepted: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', + running: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', + success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200', + partial: 'border-rose-500/30 bg-rose-500/10 text-rose-200', + failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200', +}; + +const OUTCOME_ICONS = { + submitting: '⟳', + accepted: '◎', + running: '⟳', + success: '✓', + partial: '⚠', + failed: '✗' +}; + +export default function MatchRunFeedback({ executionState, repairTask, repairLogs, detail }) { + if (!executionState?.status) return null; + + const belongsToCurrent = repairTask?.task_id === executionState.repairTaskId; + const outcome = belongsToCurrent ? (deriveRepairOutcome(repairTask) || executionState.status) : executionState.status; + const outcomeClass = OUTCOME_COLORS[outcome] || 'border-slate-700 text-slate-400'; + + const execute = repairTask?.stats?.execute || {}; + const succeeded = execute.succeeded_items; + const failed = execute.failed_items; + const candidates = detail?.match_candidates_json || []; + const best = candidates[0] || null; + const bestProvider = best ? (best.provider || best.source || '') : ''; + const afterSig = candidateSignature(candidates); + const beforeSig = executionState.beforeCandidateSignature || ''; + const explanation = buildMatchRunExplanation({ executionState, repairTask, detail, beforeSig, afterSig }); + + let conclusionTone = 'text-slate-400'; + if (executionState.status === 'submitting' || executionState.status === 'accepted' || executionState.status === 'running') { + conclusionTone = 'text-indigo-300'; + } else if (outcome === 'failed') { + conclusionTone = 'text-rose-300'; + } else if (outcome === 'partial' || (executionState.status === 'completed' && beforeSig && beforeSig === afterSig)) { + conclusionTone = 'text-amber-300'; + } else if (outcome === 'success') { + conclusionTone = 'text-cyan-300'; + } + + const errorLogs = repairLogs?.filter((l) => l.level === 'error') || []; + const statusText = executionState.status === 'submitting' ? '提交中' + : executionState.status === 'accepted' ? '等待执行' + : executionState.status === 'running' ? '执行中' + : outcome === 'success' ? '完成' + : outcome === 'partial' ? '部分失败' + : outcome === 'failed' ? '失败' + : executionState.status || ''; + + return ( +
+
+ 匹配反馈 + + {OUTCOME_ICONS[outcome] || ''} {statusText} + +
+ + {executionState.requestedProviderMode && ( +
+ 本次提交:{providerModeLabel(executionState.requestedProviderMode)} + {executionState.submittedParams?.providers?.length > 0 + ? ` · providers: ${executionState.submittedParams.providers.join(',')}` + : ''} + {executionState.repairTaskId + ? ` · 任务号 ${executionState.repairTaskId.slice(0, 8)}...` + : ''} +
+ )} + + {(executionState.status === 'completed' || executionState.status === 'failed') && ( +
+ {succeeded != null && 成功 {succeeded}} + {failed != null && 失败 {failed}} + 候选 {candidates.length} + {best && ( + + 最高 {providerModeLabel(bestProvider)} + {' '}{Number(best.score).toFixed(1)}% + + )} + {detail?.match_source && 来源 {detail.match_source}} + {detail?.match_status && 状态 {detail.match_status}} +
+ )} + + {explanation && ( +
+ {explanation} +
+ )} + + {errorLogs.length > 0 && ( +
+
匹配源错误
+ {errorLogs.slice(0, 3).map((log, i) => ( +
{log.message}
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/exceptions/RepairTaskPanel.jsx b/frontend/src/components/exceptions/RepairTaskPanel.jsx index f074328..7fd2161 100644 --- a/frontend/src/components/exceptions/RepairTaskPanel.jsx +++ b/frontend/src/components/exceptions/RepairTaskPanel.jsx @@ -1,8 +1,8 @@ // frontend/src/components/exceptions/RepairTaskPanel.jsx -import { LoaderCircle } from 'lucide-react'; +import { LoaderCircle, CheckCircle2, XCircle } from 'lucide-react'; import { isTerminalRepairStatus } from '../../utils/exceptions'; -export default function RepairTaskPanel({ repairTask, repairLogs, executionState }) { +export default function RepairTaskPanel({ repairTask, repairLogs, executionState, variant = 'default' }) { if (!repairTask && !executionState) return null; const statusLabel = { @@ -18,22 +18,70 @@ export default function RepairTaskPanel({ repairTask, repairLogs, executionState failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200' }; + const isTerminal = repairTask && isTerminalRepairStatus(repairTask.status); + + // 紧凑模式:一行状态条 + if (variant === 'compact') { + return ( +
+
+ {isTerminal && repairTask.status === 'completed' ? ( + + ) : isTerminal && repairTask.status === 'failed' ? ( + + ) : ( + + )} + 修复任务 + {repairTask && ( + + {statusLabel[repairTask.status] || repairTask.status} + + )} + {repairTask && ( + + {repairTask.task_id.slice(0, 8)}… + + )} + {repairTask?.error_message && ( + · {repairTask.error_message} + )} +
+
+ ); + } + return (
-
- - 修复任务 - {repairTask && ( - - {statusLabel[repairTask.status] || repairTask.status} - - )} +
+
+ {isTerminal && repairTask.status === 'completed' ? ( + + ) : isTerminal && repairTask.status === 'failed' ? ( + + ) : ( + + )} + 修复任务 +
+
+ {repairTask && ( + + {statusLabel[repairTask.status] || repairTask.status} + + )} +
- {repairTask && ( + {isTerminal && (
- 任务号: {repairTask.task_id} + 当前为结果复核界面 +
+ )} + {repairTask && ( +
+ 任务号: {repairTask.task_id} {repairTask.error_message && ( - {repairTask.error_message} + · {repairTask.error_message} )}
)} diff --git a/frontend/src/components/exceptions/steps/StepListen.jsx b/frontend/src/components/exceptions/steps/StepListen.jsx index 88809d0..cf79a6d 100644 --- a/frontend/src/components/exceptions/steps/StepListen.jsx +++ b/frontend/src/components/exceptions/steps/StepListen.jsx @@ -1,8 +1,8 @@ // frontend/src/components/exceptions/steps/StepListen.jsx -import { useState, useRef } from 'react'; -import { Headphones, Play, Pause, LoaderCircle } from 'lucide-react'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Headphones, Play, Pause, LoaderCircle, ListMusic, Edit3, SkipForward, ChevronRight } from 'lucide-react'; import { buildExceptionAudioUrl } from '../../../api/exceptions'; -import { formatSeconds } from '../../../utils/exceptions'; +import { formatSeconds, normalizeCandidateScore, formatCandidateScore, scoreToneClass, candidateProviderLabel } from '../../../utils/exceptions'; function InfoField({ label, value, mono = false }) { return ( @@ -13,12 +13,41 @@ function InfoField({ label, value, mono = false }) { ); } -export default function StepListen({ detailRecord, isLoading }) { +export default function StepListen({ detailRecord, isLoading, onSetWizardStep }) { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [audioError, setAudioError] = useState(''); + const [showPlaybackHint, setShowPlaybackHint] = useState(false); + + // 定义在 early return 之前,供 effect 和事件处理器使用 + const togglePlayInternal = useCallback(() => { + if (!audioRef.current) return; + if (audioRef.current.paused) { + audioRef.current.play().catch(() => setAudioError('播放器启动失败')); + } else { + audioRef.current.pause(); + } + }, []); + + useEffect(() => { + if (typeof window === 'undefined') return; + const handler = (e) => { + const tag = e.target?.tagName?.toLowerCase(); + if ( + tag === 'input' || tag === 'textarea' || tag === 'select' || + tag === 'button' || tag === 'a' || + e.target?.isContentEditable + ) return; + if (e.key === ' ' && e.target === document.body) { + e.preventDefault(); + togglePlayInternal(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [togglePlayInternal]); if (!detailRecord && !isLoading) { return ( @@ -38,64 +67,177 @@ export default function StepListen({ detailRecord, isLoading }) { const audioUrl = buildExceptionAudioUrl(detailRecord.exception_id); const ap = detailRecord.audio_props_json || {}; + const candidates = detailRecord.match_candidates_json || []; + const exceptionType = detailRecord.exception_type; const togglePlay = () => { - if (!audioRef.current) return; - if (audioRef.current.paused) { - audioRef.current.play().catch(() => setAudioError('播放器启动失败')); - } else { - audioRef.current.pause(); - } + togglePlayInternal(); + setShowPlaybackHint(true); + setTimeout(() => setShowPlaybackHint(false), 2000); }; + // 宽屏时显示更多候选 + const maxCandidates = 4; + return ( -
-
-

{detailRecord.display_title || '-'}

-

{detailRecord.filename}

-
- - - - - - +
+ {/* 左列:歌曲信息 + 播放器 */} +
+ {/* 歌曲元数据卡 */} +
+

{detailRecord.display_title || '-'}

+

{detailRecord.filename}

+
+ + + + + + +
+
+ + {/* 试听预览 */} +
+
+
+

试听预览

+

+ + 试听确认 + {showPlaybackHint && ( + + {isPlaying ? '▸ 播放中' : '▸ 已暂停'} + + )} +

+
+ +
+
-
-
-
-

试听预览

-

- 在线试听 + {/* 右列:候选摘要 + 处理动作 */} +
+ {/* 候选摘要预览 */} + {candidates.length > 0 && ( +
+

+ + 候选摘要 · {candidates.length} 个匹配

+
+ {candidates.slice(0, maxCandidates).map((candidate, i) => ( +
+
+
+
+ {candidate.title || 'Unknown'} + {i === 0 && · 最佳} +
+
+ {[candidate.artist, candidate.album].filter(Boolean).join(' · ') || '--'} +
+
+
+ + {candidateProviderLabel(candidate)} + + {normalizeCandidateScore(candidate.score) != null && ( + + {formatCandidateScore(candidate.score)} + + )} +
+
+
+ ))}
+
+ )} + + {candidates.length === 0 && exceptionType !== 'missing_tags' && ( +
+

暂无候选匹配

+
+ )} + + {/* 处理动作区(改造 3) */} +
+

+ + 处理动作 +

+

选择下一步操作

+
+ +
-

); diff --git a/frontend/src/components/exceptions/steps/StepMatch.jsx b/frontend/src/components/exceptions/steps/StepMatch.jsx index afdb350..64a092c 100644 --- a/frontend/src/components/exceptions/steps/StepMatch.jsx +++ b/frontend/src/components/exceptions/steps/StepMatch.jsx @@ -1,6 +1,7 @@ // frontend/src/components/exceptions/steps/StepMatch.jsx import { Search, LoaderCircle, ShieldAlert } from 'lucide-react'; -import { PROVIDER_MODES, chipClass, actionButtonClass, formatConfidence, providerLabel } from '../../../utils/exceptions'; +import { PROVIDER_MODES, chipClass, actionButtonClass, formatConfidence, normalizeCandidateScore, formatCandidateScore, scoreToneClass, candidateProviderLabel, scoreBreakdownItems, summarizeLowScoreReason } from '../../../utils/exceptions'; +import MatchRunFeedback from '../MatchRunFeedback'; function InfoField({ label, value }) { return ( @@ -11,23 +12,11 @@ function InfoField({ label, value }) { ); } -function StatusBadge({ status }) { - const map = { - submitting: 'border-amber-500/30 bg-amber-500/10 text-amber-200', - accepted: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', - running: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', - completed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200', - failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200' - }; - const label = { submitting: '提交中', accepted: '已提交', running: '执行中', completed: '已完成', failed: '失败' }[status]; - if (!label) return null; - return {label}; -} - export default function StepMatch({ detailRecord, isLoading, providerMode, setProviderMode, providers, setProviders, previewState, executionState, + repairTask, repairLogs, onPreview, onExecute }) { if (!detailRecord && !isLoading) { @@ -66,33 +55,56 @@ export default function StepMatch({

现有匹配候选

- {candidates.map((candidate, i) => ( + {candidates.map((candidate, i) => { + const breakdown = scoreBreakdownItems(candidate.score_breakdown); + const lowReason = summarizeLowScoreReason(candidate.score_breakdown); + return (
-
-
-
{candidate.title || 'Unknown'}
-
+ {/* 标题行:标题名 + 右侧 badge 组 */} +
+
+
{candidate.title || 'Unknown'}
+
{[candidate.artist, candidate.album].filter(Boolean).join(' · ')} {candidate.year ? ` · ${candidate.year}` : ''}
- {candidate.score != null && ( - = 0.8 ? 'bg-emerald-500/10 text-emerald-200' : - candidate.score >= 0.5 ? 'bg-amber-500/10 text-amber-200' : - 'bg-rose-500/10 text-rose-200' - }`}> - {Math.round(candidate.score * 100)}% + {/* badge 组:来源 · 最佳 · 分数 */} +
+ + {candidateProviderLabel(candidate)} - )} + {i === 0 && ( + + 最佳 + + )} + {normalizeCandidateScore(candidate.score) != null && ( + + {formatCandidateScore(candidate.score)} + + )} +
- {candidate.source && ( -
{providerLabel(candidate.source)}
+ {/* 低分原因摘要 */} + {lowReason && ( +
{lowReason}
+ )} + {/* 分数组成紧凑条 */} + {breakdown.length > 0 && ( +
+ {breakdown.map((item) => ( + + {item.label} 0 ? 'text-rose-400/70' : ''}>{item.max == null && item.value > 0 ? `-${item.value}` : item.value}{item.max != null ? `/${item.max}` : ''} + + ))} +
)}
- ))} + ); + })}
)} @@ -152,7 +164,12 @@ export default function StepMatch({ )} {executionState?.action === 'retry_match' && ( -
+ )}
diff --git a/frontend/src/hooks/useRepairTask.js b/frontend/src/hooks/useRepairTask.js index 9df4905..1a74753 100644 --- a/frontend/src/hooks/useRepairTask.js +++ b/frontend/src/hooks/useRepairTask.js @@ -86,10 +86,12 @@ export default function useRepairTask() { }); }, [repairTask]); - const registerExecution = useCallback((exceptionId, action, repairTaskId, previewPayload) => { + const registerExecution = useCallback((exceptionId, action, repairTaskId, previewPayload, executionSnapshot = {}) => { setExecutionStateByExceptionId((prev) => ({ ...prev, [exceptionId]: { + ...prev[exceptionId], + ...executionSnapshot, exceptionId, action, status: 'accepted', repairTaskId, submittedAt: new Date().toISOString(), @@ -98,10 +100,11 @@ export default function useRepairTask() { })); }, []); - const setExecuting = useCallback((exceptionId, action, previewPayload) => { + const setExecuting = useCallback((exceptionId, action, previewPayload, executionSnapshot = {}) => { setExecutionStateByExceptionId((prev) => ({ ...prev, [exceptionId]: { + ...executionSnapshot, exceptionId, action, status: 'submitting', repairTaskId: null, submittedAt: new Date().toISOString(), @@ -111,10 +114,12 @@ export default function useRepairTask() { })); }, []); - const setExecutionFailed = useCallback((exceptionId, action, errorMessage, previewPayload) => { + const setExecutionFailed = useCallback((exceptionId, action, errorMessage, previewPayload, executionSnapshot = {}) => { setExecutionStateByExceptionId((prev) => ({ ...prev, [exceptionId]: { + ...prev[exceptionId], + ...executionSnapshot, exceptionId, action, status: 'failed', error: errorMessage, diff --git a/frontend/src/pages/ExceptionPage.jsx b/frontend/src/pages/ExceptionPage.jsx index 6689451..76919e3 100644 --- a/frontend/src/pages/ExceptionPage.jsx +++ b/frontend/src/pages/ExceptionPage.jsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { previewExceptionAction, executeExceptionAction, @@ -6,7 +6,8 @@ import { import { fetchRepairTask, fetchRepairTaskLogs } from '../api/repairs'; import { BULK_ACTIONS, normalizeActionParams, - getMetadataQueueCounts, shouldRefreshExceptionListFully + getMetadataQueueCounts, shouldRefreshExceptionListFully, + isTerminalRepairStatus, candidateSignature } from '../utils/exceptions'; import useExceptionSummary from '../hooks/useExceptionSummary'; import useExceptionList from '../hooks/useExceptionList'; @@ -62,6 +63,25 @@ export default function ExceptionPage() { const metadataQueueCounts = useMemo(() => getMetadataQueueCounts(metadataQueue), [metadataQueue]); const detailExecutionState = detailRecord ? executionStateByExceptionId[detailRecord.exception_id] || null : null; + // 监听 repairTask 到达终态后刷新 wizard 详情 + useEffect(() => { + if (!repairTask?.task_id) return; + if (!isTerminalRepairStatus(repairTask.status)) return; + + const taskId = repairTask.task_id; + if (completedRefreshRef.current.has(taskId)) return; + completedRefreshRef.current.add(taskId); + + refreshDetail(); + refreshMetadataQueue(); + refreshSummary(); + + if (shouldRefreshExceptionListFully(repairTask)) { + refreshList(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [repairTask, refreshDetail, refreshMetadataQueue, refreshSummary, refreshList]); + const bulkState = useMemo(() => { if (!selectedIds.length) return { disabled: true, reason: '', actions: [] }; const selectedItems = items.filter((item) => selectedIds.includes(item.exception_id)); @@ -104,16 +124,21 @@ export default function ExceptionPage() { if (!exceptionIds.length || !action) return; setPreviewState({ loading: true, payload: null, error: '', action }); setExecuteError(''); + const params = normalizeActionParams(action, { + ...actionParams, + provider_mode: providerMode, + providers + }); try { const payload = await previewExceptionAction({ exception_ids: exceptionIds, action, - params: normalizeActionParams(action, actionParams) + params }); setPreviewState({ loading: false, payload, error: '', action }); } catch (err) { setPreviewState({ loading: false, payload: null, error: err.message || '预览失败', action }); } - }, [selectedIds, detailRecord, actionParams, setPreviewState, setExecuteError]); + }, [selectedIds, detailRecord, actionParams, providerMode, providers, setPreviewState, setExecuteError]); const handleExecute = useCallback(async (action) => { const exceptionIds = selectedIds.length ? selectedIds : detailRecord ? [detailRecord.exception_id] : []; @@ -125,19 +150,30 @@ export default function ExceptionPage() { const isSingleItem = exceptionIds.length === 1; const currentExceptionId = isSingleItem ? detailRecord?.exception_id : null; + const params = normalizeActionParams(action, { + ...actionParams, + provider_mode: providerMode, + providers + }); + const executionSnapshot = action === 'retry_match' ? { + requestedProviderMode: providerMode, + requestedProviders: params.providers, + submittedParams: params, + beforeCandidateSignature: candidateSignature(detailRecord?.match_candidates_json || []) + } : {}; setExecuteError(''); if (currentExceptionId) { - setExecuting(currentExceptionId, action, previewState.payload); + setExecuting(currentExceptionId, action, previewState.payload, executionSnapshot); } try { const payload = await executeExceptionAction({ exception_ids: exceptionIds, action, - params: normalizeActionParams(action, actionParams) + params }); if (currentExceptionId) { - registerExecution(currentExceptionId, action, payload.repair_task_id, previewState.payload); + registerExecution(currentExceptionId, action, payload.repair_task_id, previewState.payload, executionSnapshot); } const tp = await fetchRepairTask(payload.repair_task_id); const lp = await fetchRepairTaskLogs(payload.repair_task_id, 1, 20); @@ -145,11 +181,11 @@ export default function ExceptionPage() { setRepairLogs(lp.logs); } catch (err) { if (currentExceptionId) { - setExecutionFailed(currentExceptionId, action, err.message || '执行失败', previewState.payload); + setExecutionFailed(currentExceptionId, action, err.message || '执行失败', previewState.payload, executionSnapshot); } setExecuteError(err.message || '执行失败'); } - }, [selectedIds, detailRecord, actionParams, previewState, setExecuting, registerExecution, setExecutionFailed, setRepairTask, setRepairLogs, setExecuteError]); + }, [selectedIds, detailRecord, actionParams, providerMode, providers, previewState, setExecuting, registerExecution, setExecutionFailed, setRepairTask, setRepairLogs, setExecuteError]); const handleUpdateMetadata = useCallback((key, value) => { setActionParams((prev) => ({ diff --git a/frontend/src/pages/LibraryPage.jsx b/frontend/src/pages/LibraryPage.jsx index 5e65995..00dd430 100644 --- a/frontend/src/pages/LibraryPage.jsx +++ b/frontend/src/pages/LibraryPage.jsx @@ -175,7 +175,7 @@ export default function LibraryPage() { } return ( -
+

音乐库总览

@@ -231,7 +231,7 @@ export default function LibraryPage() { />
-
+
@@ -292,7 +292,7 @@ export default function LibraryPage() {
) : null} -
+
@@ -363,6 +363,7 @@ export default function LibraryPage() {
+
+
{moveSuccess ? (
diff --git a/frontend/src/utils/exceptions.js b/frontend/src/utils/exceptions.js index 57a7b23..33cc941 100644 --- a/frontend/src/utils/exceptions.js +++ b/frontend/src/utils/exceptions.js @@ -105,9 +105,113 @@ export function isTerminalRepairStatus(status) { return status === 'completed' || status === 'failed'; } +export function candidateSignature(candidates = []) { + return candidates + .map((c) => [ + c.provider || c.source || '', + c.title || '', + c.artist || '', + c.album || '', + Number(c.score ?? 0).toFixed(1) + ].join('|')) + .join('||'); +} + +export function providerModeLabel(modeId) { + const mode = PROVIDER_MODES.find((m) => m.id === modeId); + return mode ? mode.label : modeId || '多源并行'; +} + +export function deriveRepairOutcome(repairTask) { + if (!repairTask) return null; + const execute = repairTask.stats?.execute || {}; + if (repairTask.status === 'failed') return 'failed'; + if ((execute.failed_items || 0) > 0 && (execute.succeeded_items || 0) === 0) return 'failed'; + if ((execute.failed_items || 0) > 0) return 'partial'; + if (repairTask.status === 'completed') return 'success'; + return repairTask.status || 'accepted'; +} + +export function buildMatchRunExplanation({ executionState, repairTask, detail, beforeSig, afterSig }) { + if (!executionState || !executionState.status) return ''; + if (executionState.status === 'submitting') return '正在提交匹配请求...'; + if (executionState.status === 'accepted') return '匹配请求已提交,等待后端执行。'; + if (executionState.status === 'running') return '后端正在执行匹配任务...'; + + const outcome = deriveRepairOutcome(repairTask); + if (outcome === 'failed') return repairTask?.error_message || executionState.error || '匹配执行失败,候选未更新。'; + + const execute = repairTask?.stats?.execute || {}; + const succeeded = execute.succeeded_items || 0; + const failed = execute.failed_items || 0; + + if (outcome === 'partial') { + const parts = [`匹配完成,${succeeded} 项成功,${failed} 项失败。`]; + if (detail?.match_message) parts.push(detail.match_message); + return parts.join(' '); + } + + const reqMode = executionState.requestedProviderMode; + const expectedProviders = PROVIDER_MODES.find((m) => m.id === reqMode)?.providers || []; + const submittedProviders = executionState.submittedParams?.providers || []; + const providerMismatch = JSON.stringify(submittedProviders) !== JSON.stringify(expectedProviders); + + const lastRepairId = detail?.last_repair_task_id; + const thisRepairId = executionState.repairTaskId; + const notRefreshed = lastRepairId && thisRepairId && String(lastRepairId) !== String(thisRepairId); + if (notRefreshed) return '当前详情尚未刷新到本次任务结果,正在重新拉取。'; + + const candidates = detail?.match_candidates_json || []; + const unchanged = beforeSig === afterSig; + const best = candidates[0] || null; + const bestProvider = best ? (best.provider || best.source || '') : ''; + const bestScore = best?.score != null ? Number(best.score).toFixed(1) : '--'; + const bestTitle = best?.title || ''; + const bestMatchesExpected = expectedProviders.length === 0 || expectedProviders.includes(bestProvider); + const parts = []; + + if (candidates.length === 0) { + parts.push('本次匹配完成,但没有找到候选。'); + if (detail?.match_message) parts.push(detail.match_message); + return parts.join(' '); + } + + if (providerMismatch) { + parts.push(`选择和提交参数不一致:界面选择 ${providerModeLabel(reqMode)},但后端收到 ${submittedProviders.join(',') || '多源并行'}。`); + } + + if (unchanged && candidates.length > 0) { + parts.push('匹配完成,但候选列表与执行前一致,没有发现更优结果。'); + if (best) parts.push(`最高候选仍是 ${providerModeLabel(bestProvider)} · ${bestTitle} · ${bestScore} 分。`); + } + + if (!unchanged && !bestMatchesExpected && expectedProviders.length > 0) { + parts.push(`本次按 ${providerModeLabel(reqMode)} 执行,但最高候选来自 ${providerModeLabel(bestProvider)}。`); + } + + if (!unchanged && best && Number(bestScore) < 80) { + parts.push('找到候选,但分数不足或差距不够,仍需人工复核。'); + } + + if (parts.length === 0) { + parts.push('匹配完成,候选已更新。'); + } + + if (failed > 0) { + parts.push(`${failed} 个匹配源执行失败。`); + } + + return parts.join(' '); +} + export function normalizeActionParams(action, params) { if (action === 'retry_match') { - return { provider_mode: params.provider_mode || 'all', providers: params.providers || [] }; + const providerMode = params.provider_mode || 'all'; + const selectedMode = PROVIDER_MODES.find((mode) => mode.id === providerMode); + return { + provider_mode: providerMode, + providers: params.providers ?? selectedMode?.providers ?? [] + }; } if (action === 'save_and_organize' || action === 'edit_metadata') { return { metadata_patch: { ...(params.metadata_patch || {}) } }; @@ -229,3 +333,111 @@ export function shouldRefreshExceptionListFully(repairTask) { if (itemCount !== 1) return true; return !['retry_match', 'select_match_candidate', 'edit_metadata'].includes(action); } + +/** + * 归一化候选分数到百分制(0-100 范围)。 + * 如果 score <= 1,视为概率分数(0-1),乘以 100; + * 如果 score > 1,视为已是百分制(0-100),直接返回。 + * 无效值返回 null。 + */ +export function normalizeCandidateScore(score) { + const value = Number(score); + if (!Number.isFinite(value)) return null; + if (value <= 1) return value * 100; + return value; +} + +/** + * 格式化候选分数为带 "%" 的字符串,保留 1 位小数。 + * 无效值返回空字符串。 + */ +export function formatCandidateScore(score) { + const normalized = normalizeCandidateScore(score); + if (normalized == null) return ''; + return `${normalized.toFixed(1)}%`; +} + +/** + * 根据归一化后的百分制分数返回 Tailwind 色调类名。 + * >= 80 → emerald(绿),>= 50 → amber(琥珀),否则 → rose(玫红)。 + * 无效值返回空字符串。 + */ +export function scoreToneClass(score) { + const normalized = normalizeCandidateScore(score); + if (normalized == null) return ''; + if (normalized >= 80) return 'bg-emerald-500/10 text-emerald-200'; + if (normalized >= 50) return 'bg-amber-500/10 text-amber-200'; + return 'bg-rose-500/10 text-rose-200'; +} + +/** + * 返回候选的来源提供者标签。 + * 优先 candidate.provider,回退 candidate.source,再回退显示"推荐候选"。 + */ +export function candidateProviderLabel(candidate) { + return providerLabel(candidate?.provider || candidate?.source); +} + +/** + * 固定顺序的分数组成项,每项包含 key/label/value/max。 + * 无效值按 0 处理。version_penalty 作为扣分项(max = null)。 + */ +export function scoreBreakdownItems(scoreBreakdown) { + if (!scoreBreakdown || typeof scoreBreakdown !== 'object') return []; + const spec = [ + { key: 'fingerprint', label: '指纹/搜索', max: 30 }, + { key: 'title', label: '标题', max: 20 }, + { key: 'artist', label: '艺人', max: 15 }, + { key: 'album', label: '专辑', max: 10 }, + { key: 'duration', label: '时长', max: 10 }, + { key: 'track_disc', label: '曲序', max: 5 }, + { key: 'album_context', label: '专辑上下文', max: 10 }, + { key: 'version_penalty', label: '版本扣分', max: null }, + ]; + return spec.map(({ key, label, max }) => { + const raw = scoreBreakdown[key]; + const value = Number.isFinite(Number(raw)) ? Number(raw) : 0; + return { key, label, value, max }; + }); +} + +/** + * 根据 score_breakdown 生成一句低分原因摘要。 + * 没有 breakdown 或没有明显低分项时返回空字符串。 + */ +export function summarizeLowScoreReason(scoreBreakdown) { + if (!scoreBreakdown || typeof scoreBreakdown !== 'object') return ''; + + const rules = [ + { key: 'fingerprint', label: '指纹', max: 30, half: 15 }, + { key: 'title', label: '标题', max: 20, half: 10 }, + { key: 'artist', label: '艺人', max: 15, half: 7.5 }, + { key: 'album', label: '专辑', max: 10, half: 5 }, + { key: 'duration', label: '时长', max: 10, half: 5 }, + { key: 'track_disc', label: '曲序', max: 5, half: 2.5 }, + { key: 'album_context', label: '专辑上下文', max: 10, half: 5 }, + ]; + + const lowItems = rules + .filter(({ key, half, max: maxVal }) => { + const raw = scoreBreakdown[key]; + const value = Number.isFinite(Number(raw)) ? Number(raw) : 0; + return maxVal != null && value < half; + }) + .map((r) => r.label); + + const hasPenalty = (() => { + const raw = scoreBreakdown.version_penalty; + const value = Number.isFinite(Number(raw)) ? Number(raw) : 0; + return value > 0; + })(); + + const parts = []; + if (lowItems.length > 0) { + parts.push(`低分原因:${lowItems.join('、')}匹配不足`); + } + if (hasPenalty) { + parts.push('版本扣分'); + } + return parts.join(' · '); +}