Improve exception workflow layout and match feedback
This commit is contained in:
@@ -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 (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-slate-950 font-sans text-slate-300">
|
||||
<div className="flex w-64 flex-col border-r border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center space-x-3 p-6 text-emerald-400">
|
||||
<Activity className="h-8 w-8" />
|
||||
<span className="text-2xl font-bold tracking-wider text-white">音流工坊</span>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* 动态粒子背景(可选简单渐变,更高性能) */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 -left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-0 -right-1/4 w-96 h-96 bg-secondary/20 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
<div className="mb-2 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
主菜单
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-slate-800 p-4 text-xs text-slate-500">
|
||||
Navidrome Auto-Ingest Engine v1.2.0
|
||||
|
||||
{/* 顶部玻璃导航栏 */}
|
||||
<header className="sticky top-0 z-50 backdrop-blur-xl bg-slate-900/60 border-b border-white/10">
|
||||
<div className="max-w-[1800px] mx-auto px-6 py-3 flex items-center justify-between">
|
||||
{/* Logo + 状态指示器 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/workbench" className="flex items-center gap-2 group">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary shadow-glow" />
|
||||
<h1 className="text-xl font-bold gradient-text">MusicWorkshop</h1>
|
||||
</Link>
|
||||
<div className="h-6 w-px bg-white/20" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
connState === 'connected' ? 'bg-emerald-500 animate-pulse' :
|
||||
connState === 'connecting' ? 'bg-yellow-500' :
|
||||
connState === 'reconnecting' ? 'bg-orange-500 animate-pulse' :
|
||||
'bg-slate-500'
|
||||
}`} />
|
||||
<span className="text-xs text-slate-400">
|
||||
{connState === 'connected' ? '实时连接' :
|
||||
connState === 'connecting' ? '连接中...' :
|
||||
connState === 'reconnecting' ? '重连中...' :
|
||||
connState === 'closed' ? '未连接' : '空闲'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900/50 px-6">
|
||||
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
{isWorkbenchPage && (
|
||||
<div className="flex items-center space-x-2 rounded-full border border-slate-700/50 bg-slate-800/50 px-3 py-1.5">
|
||||
{connState === 'connected' ? (
|
||||
<>
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<Wifi className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-xs font-medium text-emerald-400">
|
||||
实时连接中 (WS)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-400">轮询兜底中</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-slate-400">系统状态:</span>
|
||||
<span
|
||||
className={`rounded px-2.5 py-1 text-xs font-semibold ${
|
||||
taskState === 'unconfigured'
|
||||
? 'bg-slate-800 text-slate-400'
|
||||
: taskState === 'ready'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: taskState === 'running'
|
||||
? 'border border-emerald-500/30 bg-emerald-500/20 text-emerald-400'
|
||||
: taskState === 'failed'
|
||||
? 'border border-rose-500/30 bg-rose-500/20 text-rose-400'
|
||||
: 'bg-slate-700 text-white'
|
||||
}`}
|
||||
{/* 导航链接 */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-white/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{taskState === 'unconfigured'
|
||||
? '未配置'
|
||||
: taskState === 'ready'
|
||||
? '已配置,待机中'
|
||||
: taskState === 'running'
|
||||
? '任务执行中'
|
||||
: taskState === 'failed'
|
||||
? '任务失败'
|
||||
: '批次完成'}
|
||||
</span>
|
||||
</div>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 移动端菜单按钮 (简化,未完整实现) */}
|
||||
<button className="md:hidden p-2 rounded-lg hover:bg-white/10">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={`flex-1 min-h-0 p-6 ${
|
||||
location.pathname === '/workbench'
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto'
|
||||
}`}
|
||||
>
|
||||
{/* 主内容区域 */}
|
||||
<main className={`max-w-[1800px] mx-auto px-6 py-4 ${
|
||||
location.pathname === '/settings'
|
||||
? 'overflow-y-auto'
|
||||
: 'h-[calc(100vh-64px)] overflow-hidden'
|
||||
}`}>
|
||||
<div className={`animate-fade-in h-full ${
|
||||
location.pathname === '/settings' ? '' : 'flex flex-col'
|
||||
}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButton({ to, icon: Icon, label }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() } });
|
||||
try {
|
||||
const params = normalizeActionParams(action, {
|
||||
metadata_patch: metadataPatch,
|
||||
provider_mode: providerMode,
|
||||
providers
|
||||
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 {
|
||||
// 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 (
|
||||
<button key={mode.id} onClick={() => { setProviderMode(mode.id); setProviders(mode.providers); }}
|
||||
<button key={mode.id} onClick={() => setProviderMode(mode.id)}
|
||||
className={chipClass(active)}>
|
||||
{mode.label}
|
||||
</button>
|
||||
@@ -634,7 +670,12 @@ export default function MissingTagsInlinePanel({
|
||||
</div>
|
||||
) : null}
|
||||
{executionState?.action === 'retry_match' ? (
|
||||
<div className="mt-3">{renderInlineBadge(executionState.status)}</div>
|
||||
<MatchRunFeedback
|
||||
executionState={executionState}
|
||||
repairTask={repairTask}
|
||||
repairLogs={repairLogs}
|
||||
detail={detail}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -801,7 +842,8 @@ export default function MissingTagsInlinePanel({
|
||||
<h4 className="text-sm font-medium text-white">执行操作</h4>
|
||||
<p className="mt-1 text-xs text-slate-400">入库前请确认右侧预览结果。忽略只改状态,删除会真实删除文件。</p>
|
||||
</div>
|
||||
{renderInlineBadge(executionState?.status)}
|
||||
{/* retry_match 已用 MatchRunFeedback 展示,不重复显示 */}
|
||||
{executionState?.action !== 'retry_match' && renderInlineBadge(executionState?.status)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{/* Primary: Ingest */}
|
||||
|
||||
@@ -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 <StepListen detailRecord={detailRecord} isLoading={isDetailLoading} />;
|
||||
return <StepListen detailRecord={detailRecord} isLoading={isDetailLoading} onSetWizardStep={onSetWizardStep} />;
|
||||
case 'match':
|
||||
return (
|
||||
<StepMatch
|
||||
@@ -49,6 +93,7 @@ export default function ExceptionWizard({
|
||||
providerMode={providerMode} setProviderMode={setProviderMode}
|
||||
providers={providers} setProviders={setProviders}
|
||||
previewState={previewState} executionState={executionState}
|
||||
repairTask={repairTask} repairLogs={repairLogs}
|
||||
onPreview={onPreview} onExecute={onExecute}
|
||||
/>
|
||||
);
|
||||
@@ -79,7 +124,7 @@ export default function ExceptionWizard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-120px)] flex-col gap-6 py-6">
|
||||
<div className="flex min-h-[calc(100vh-64px)] flex-col gap-4 py-2">
|
||||
<section className="rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_left,_rgba(99,102,241,0.08),_transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
|
||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
@@ -97,18 +142,24 @@ export default function ExceptionWizard({
|
||||
metadataTotal={metadataTotal}
|
||||
metadataQueueCounts={metadataQueueCounts}
|
||||
viewMode="wizard"
|
||||
activeExceptionType={activeExceptionType}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<WizardProgressBar currentStep={wizardStep} onSelectStep={onSetWizardStep} />
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
{repairTask && (
|
||||
<RepairTaskPanel variant="compact" repairTask={repairTask} repairLogs={repairLogs} executionState={executionState} />
|
||||
)}
|
||||
<button onClick={onSwitchToAdvanced} className={actionButtonClass(true)}>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
高级处理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[minmax(320px,0.42fr)_minmax(0,1fr)]">
|
||||
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<section className="min-h-[420px] overflow-hidden rounded-[28px] border border-slate-800/90 bg-slate-950/80 shadow-[0_24px_80px_rgba(2,6,23,0.35)]">
|
||||
<div className="border-b border-slate-800/80 p-5">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-slate-500">待处理队列</p>
|
||||
@@ -127,7 +178,7 @@ export default function ExceptionWizard({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="min-h-0 overflow-auto rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_right,_rgba(34,197,94,0.08),_transparent_28%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
|
||||
<section className="min-h-0 rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_right,_rgba(34,197,94,0.08),_transparent_28%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
|
||||
{!detailRecord && !isDetailLoading && wizardStep !== 'select' ? (
|
||||
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-slate-500">
|
||||
<Music2 className="mb-4 h-12 w-12 opacity-30" />
|
||||
@@ -136,18 +187,22 @@ export default function ExceptionWizard({
|
||||
) : detailError ? (
|
||||
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">{detailError}</div>
|
||||
) : (
|
||||
<div className="space-y-4 pb-8">
|
||||
{wizardStep !== 'select' && renderStep()}
|
||||
<div className="space-y-4 pb-4">
|
||||
{wizardStep !== 'select' && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="rounded-full border border-indigo-500/40 bg-indigo-500/10 px-3 py-1 text-[11px] font-medium text-indigo-200">
|
||||
当前步骤:{WIZARD_STEPS.find((s) => s.id === wizardStep)?.label || wizardStep}
|
||||
</span>
|
||||
</div>
|
||||
{renderStep()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{repairTask && (
|
||||
<div className="rounded-2xl border border-slate-800/90 bg-slate-950/80 p-4">
|
||||
<RepairTaskPanel repairTask={repairTask} repairLogs={repairLogs} executionState={executionState} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<Check className="h-3 w-3" />
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full text-[10px]">{index + 1}</span>
|
||||
<span className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${
|
||||
isCurrent ? 'bg-indigo-400/20' : ''
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
{step.label}
|
||||
</button>
|
||||
{index < WIZARD_STEPS.length - 1 && (
|
||||
<div className={`h-px w-4 ${isCompleted ? 'bg-emerald-500/30' : 'bg-slate-700'}`} />
|
||||
<div className={`h-px w-4 ${isCompleted ? 'bg-emerald-500/40' : isCurrent ? 'bg-indigo-500/30' : 'bg-slate-700'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-200">匹配反馈</span>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] ${outcomeClass}`}>
|
||||
{OUTCOME_ICONS[outcome] || ''} {statusText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{executionState.requestedProviderMode && (
|
||||
<div className="mt-2 text-[11px] text-slate-400">
|
||||
本次提交:{providerModeLabel(executionState.requestedProviderMode)}
|
||||
{executionState.submittedParams?.providers?.length > 0
|
||||
? ` · providers: ${executionState.submittedParams.providers.join(',')}`
|
||||
: ''}
|
||||
{executionState.repairTaskId
|
||||
? ` · 任务号 ${executionState.repairTaskId.slice(0, 8)}...`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(executionState.status === 'completed' || executionState.status === 'failed') && (
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||
{succeeded != null && <span>成功 <span className="text-emerald-400/80">{succeeded}</span></span>}
|
||||
{failed != null && <span>失败 <span className="text-rose-400/80">{failed}</span></span>}
|
||||
<span>候选 <span className="text-slate-300">{candidates.length}</span></span>
|
||||
{best && (
|
||||
<span>
|
||||
最高 <span className="text-slate-300">{providerModeLabel(bestProvider)}</span>
|
||||
{' '}<span className="text-slate-300">{Number(best.score).toFixed(1)}%</span>
|
||||
</span>
|
||||
)}
|
||||
{detail?.match_source && <span>来源 <span className="text-slate-300">{detail.match_source}</span></span>}
|
||||
{detail?.match_status && <span>状态 <span className="text-slate-300">{detail.match_status}</span></span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explanation && (
|
||||
<div className={`mt-2 text-[11px] leading-relaxed ${conclusionTone}`}>
|
||||
{explanation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorLogs.length > 0 && (
|
||||
<div className="mt-2 rounded-xl border border-rose-900/30 bg-rose-950/10 p-2">
|
||||
<div className="text-[10px] font-medium text-rose-300/80">匹配源错误</div>
|
||||
{errorLogs.slice(0, 3).map((log, i) => (
|
||||
<div key={i} className="mt-1 text-[10px] text-rose-200/60">{log.message}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-w-0 rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2">
|
||||
<div className="flex items-center gap-2 truncate text-xs">
|
||||
{isTerminal && repairTask.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
) : isTerminal && repairTask.status === 'failed' ? (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0 text-rose-400" />
|
||||
) : (
|
||||
<LoaderCircle className={`h-3.5 w-3.5 shrink-0 text-indigo-300 ${repairTask && !isTerminal ? 'animate-spin' : ''}`} />
|
||||
)}
|
||||
<span className="shrink-0 font-medium text-slate-200">修复任务</span>
|
||||
{repairTask && (
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] ${statusColor[repairTask.status] || ''}`}>
|
||||
{statusLabel[repairTask.status] || repairTask.status}
|
||||
</span>
|
||||
)}
|
||||
{repairTask && (
|
||||
<span className="truncate font-mono text-slate-500" title={repairTask.task_id}>
|
||||
{repairTask.task_id.slice(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
{repairTask?.error_message && (
|
||||
<span className="truncate text-rose-400">· {repairTask.error_message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<LoaderCircle className={`h-3 w-3 ${repairTask && !isTerminalRepairStatus(repairTask.status) ? 'animate-spin' : ''}`} />
|
||||
<span>修复任务</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isTerminal && repairTask.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
) : isTerminal && repairTask.status === 'failed' ? (
|
||||
<XCircle className="h-4 w-4 text-rose-400" />
|
||||
) : (
|
||||
<LoaderCircle className={`h-4 w-4 text-indigo-300 ${repairTask && !isTerminal ? 'animate-spin' : ''}`} />
|
||||
)}
|
||||
<span className="text-sm font-medium text-slate-200">修复任务</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{repairTask && (
|
||||
<span className={`ml-auto rounded-full px-2 py-0.5 text-[11px] ${statusColor[repairTask.status] || ''}`}>
|
||||
<span className={`rounded-full border px-3 py-1 text-xs font-medium ${statusColor[repairTask.status] || ''}`}>
|
||||
{statusLabel[repairTask.status] || repairTask.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{repairTask && (
|
||||
</div>
|
||||
{isTerminal && (
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
任务号: {repairTask.task_id}
|
||||
当前为结果复核界面
|
||||
</div>
|
||||
)}
|
||||
{repairTask && (
|
||||
<div className="mt-1.5 text-[11px] text-slate-500">
|
||||
任务号: <span className="font-mono text-slate-400">{repairTask.task_id}</span>
|
||||
{repairTask.error_message && (
|
||||
<span className="ml-2 text-rose-400">{repairTask.error_message}</span>
|
||||
<span className="ml-2 text-rose-400">· {repairTask.error_message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,22 +67,27 @@ 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 (
|
||||
<div className="space-y-4 xl:space-y-0 xl:grid xl:grid-cols-[minmax(0,1.05fr)_minmax(360px,0.8fr)] xl:gap-4">
|
||||
{/* 左列:歌曲信息 + 播放器 */}
|
||||
<div className="space-y-4">
|
||||
{/* 歌曲元数据卡 */}
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{detailRecord.display_title || '-'}</h3>
|
||||
<p className="mt-1 font-mono text-xs text-indigo-300/80">{detailRecord.filename}</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-300">
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 xl:grid-cols-3 text-xs text-slate-300">
|
||||
<InfoField label="格式" value={ap.format || '--'} />
|
||||
<InfoField label="编码" value={ap.codec || '--'} />
|
||||
<InfoField label="采样率" value={ap.sample_rate ? `${ap.sample_rate} Hz` : '--'} />
|
||||
@@ -63,17 +97,27 @@ export default function StepListen({ detailRecord, isLoading }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 试听预览 */}
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">试听预览</p>
|
||||
<h4 className="mt-2 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Headphones className="h-4 w-4 text-emerald-300" />在线试听
|
||||
<h4 className="mt-1 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Headphones className="h-4 w-4 text-emerald-400" />
|
||||
试听确认
|
||||
{showPlaybackHint && (
|
||||
<span className={`ml-2 text-[11px] font-normal transition-opacity ${
|
||||
isPlaying ? 'text-emerald-300' : 'text-slate-400'
|
||||
}`}>
|
||||
{isPlaying ? '▸ 播放中' : '▸ 已暂停'}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-emerald-500/40 bg-emerald-500/10 text-emerald-200 transition hover:bg-emerald-500/20"
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-emerald-500/40 bg-emerald-500/10 text-emerald-200 transition hover:bg-emerald-500/20"
|
||||
title={isPlaying ? '暂停 (Space)' : '播放 (Space)'}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
|
||||
</button>
|
||||
@@ -86,16 +130,114 @@ export default function StepListen({ detailRecord, isLoading }) {
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="relative">
|
||||
<input type="range" min="0" max={duration || 0} step="0.1"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={(e) => { const v = Number(e.target.value); setCurrentTime(v); if (audioRef.current) audioRef.current.currentTime = v; }}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-800 accent-emerald-400"
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-800 accent-emerald-400
|
||||
[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-emerald-400
|
||||
[&::-webkit-slider-thumb]:shadow-[0_0_6px_rgba(52,211,153,0.5)]
|
||||
[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full
|
||||
[&::-moz-range-thumb]:bg-emerald-400 [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between font-mono text-[11px] text-slate-500">
|
||||
<span>{formatSeconds(currentTime)}</span><span>{formatSeconds(duration)}</span>
|
||||
<span>{formatSeconds(currentTime)}</span>
|
||||
<span className={`text-[10px] ${isPlaying ? 'text-emerald-400/70' : 'text-slate-600'}`}>
|
||||
{isPlaying ? '播放中' : '已暂停'}
|
||||
</span>
|
||||
<span>{formatSeconds(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{audioError ? (
|
||||
<p className="mt-3 text-xs text-rose-300">{audioError}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-[10px] text-slate-600">按 Space 键播放/暂停</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右列:候选摘要 + 处理动作 */}
|
||||
<div className="space-y-4">
|
||||
{/* 候选摘要预览 */}
|
||||
{candidates.length > 0 && (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<h4 className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<ListMusic className="h-4 w-4 text-indigo-400" />
|
||||
候选摘要 · {candidates.length} 个匹配
|
||||
</h4>
|
||||
<div className="mt-3 space-y-2">
|
||||
{candidates.slice(0, maxCandidates).map((candidate, i) => (
|
||||
<div key={i} className={`rounded-xl border p-3 text-xs ${
|
||||
i === 0
|
||||
? 'border-indigo-400/30 bg-indigo-500/6'
|
||||
: 'border-slate-800/70 bg-slate-900/40'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate font-medium ${i === 0 ? 'text-indigo-100' : 'text-slate-200'}`}>
|
||||
{candidate.title || 'Unknown'}
|
||||
{i === 0 && <span className="ml-1.5 text-[10px] text-indigo-400">· 最佳</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-slate-400">
|
||||
{[candidate.artist, candidate.album].filter(Boolean).join(' · ') || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-slate-700 bg-slate-800/60 px-2 py-0.5 text-[9px] text-slate-300">
|
||||
{candidateProviderLabel(candidate)}
|
||||
</span>
|
||||
{normalizeCandidateScore(candidate.score) != null && (
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${scoreToneClass(candidate.score)}`}>
|
||||
{formatCandidateScore(candidate.score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidates.length === 0 && exceptionType !== 'missing_tags' && (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 p-4 text-center text-xs text-slate-500">
|
||||
<p>暂无候选匹配</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 处理动作区(改造 3) */}
|
||||
<div className="rounded-2xl border border-indigo-900/30 bg-indigo-950/15 p-4">
|
||||
<h4 className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<ChevronRight className="h-4 w-4 text-indigo-400" />
|
||||
处理动作
|
||||
</h4>
|
||||
<p className="mt-1 text-[11px] text-slate-500">选择下一步操作</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => onSetWizardStep('match')}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-indigo-500 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-indigo-500/20 transition hover:bg-indigo-400"
|
||||
>
|
||||
<ListMusic className="h-4 w-4" />
|
||||
查看候选
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSetWizardStep('edit')}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-slate-700 bg-slate-800/70 px-4 py-2.5 text-sm font-medium text-slate-200 transition hover:border-slate-600 hover:bg-slate-700"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
进入手动编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSetWizardStep('select')}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-2.5 text-sm font-medium text-slate-400 transition hover:border-slate-700 hover:text-slate-300"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
暂不处理 / 跳过
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{audioError ? <p className="mt-3 text-xs text-rose-300">{audioError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 <span className={`rounded-full border px-2.5 py-1 text-xs ${map[status]}`}>{label}</span>;
|
||||
}
|
||||
|
||||
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({
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<h4 className="text-sm font-medium text-white">现有匹配候选</h4>
|
||||
<div className="mt-3 space-y-2">
|
||||
{candidates.map((candidate, i) => (
|
||||
{candidates.map((candidate, i) => {
|
||||
const breakdown = scoreBreakdownItems(candidate.score_breakdown);
|
||||
const lowReason = summarizeLowScoreReason(candidate.score_breakdown);
|
||||
return (
|
||||
<div key={i} className={`rounded-xl border p-3 ${
|
||||
i === 0 ? 'border-indigo-400/30 bg-indigo-500/5' : 'border-slate-800 bg-slate-900/60'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-100">{candidate.title || 'Unknown'}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{/* 标题行:标题名 + 右侧 badge 组 */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-100">{candidate.title || 'Unknown'}</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">
|
||||
{[candidate.artist, candidate.album].filter(Boolean).join(' · ')}
|
||||
{candidate.year ? ` · ${candidate.year}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{candidate.score != null && (
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] font-medium ${
|
||||
candidate.score >= 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 组:来源 · 最佳 · 分数 */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-slate-700 bg-slate-800/60 px-2 py-0.5 text-[10px] text-slate-300">
|
||||
{candidateProviderLabel(candidate)}
|
||||
</span>
|
||||
{i === 0 && (
|
||||
<span className="rounded-full border border-indigo-400/40 bg-indigo-500/10 px-2 py-0.5 text-[10px] text-indigo-200">
|
||||
最佳
|
||||
</span>
|
||||
)}
|
||||
{normalizeCandidateScore(candidate.score) != null && (
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${scoreToneClass(candidate.score)}`}>
|
||||
{formatCandidateScore(candidate.score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{candidate.source && (
|
||||
<div className="mt-2 text-[11px] text-slate-500">{providerLabel(candidate.source)}</div>
|
||||
</div>
|
||||
{/* 低分原因摘要 */}
|
||||
{lowReason && (
|
||||
<div className="mt-2 text-[11px] leading-4 text-amber-300/80">{lowReason}</div>
|
||||
)}
|
||||
{/* 分数组成紧凑条 */}
|
||||
{breakdown.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-x-2.5 gap-y-1 text-[10px] text-slate-500">
|
||||
{breakdown.map((item) => (
|
||||
<span key={item.key} className="whitespace-nowrap">
|
||||
{item.label} <span className={item.max != null && item.value < item.max / 2 ? 'text-amber-400/70' : item.max === null && item.value > 0 ? 'text-rose-400/70' : ''}>{item.max == null && item.value > 0 ? `-${item.value}` : item.value}</span>{item.max != null ? `/${item.max}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -152,7 +164,12 @@ export default function StepMatch({
|
||||
)}
|
||||
|
||||
{executionState?.action === 'retry_match' && (
|
||||
<div className="mt-3"><StatusBadge status={executionState.status} /></div>
|
||||
<MatchRunFeedback
|
||||
executionState={executionState}
|
||||
repairTask={repairTask}
|
||||
repairLogs={repairLogs}
|
||||
detail={detailRecord}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function LibraryPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full py-6">
|
||||
<div className="relative flex h-full min-h-0 flex-col py-6">
|
||||
<div className="mb-8 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="mb-2 text-2xl font-bold text-white">音乐库总览</h2>
|
||||
@@ -231,7 +231,7 @@ export default function LibraryPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-lg">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-lg">
|
||||
<div className="border-b border-slate-800 bg-slate-900/70 px-5 py-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
@@ -292,7 +292,7 @@ export default function LibraryPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
@@ -363,6 +363,7 @@ export default function LibraryPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
@@ -391,6 +392,7 @@ export default function LibraryPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{moveSuccess ? (
|
||||
<div className="fixed bottom-6 right-6 z-30 max-w-sm rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100 shadow-2xl">
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user