);
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
;
-}
-
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(' · ');
+}