feat: add visual components and wizard step components

Phase 3-4: ExceptionStatsBar, ExceptionTypeNav, RepairTaskPanel,
and 5 wizard step components (StepSelect, StepListen, StepMatch,
StepEdit, StepConfirm) extracted from the monolithic ExceptionPage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-07 21:02:10 +08:00
parent b97f5debac
commit ad9501fb11
9 changed files with 962 additions and 0 deletions
@@ -0,0 +1,37 @@
// frontend/src/components/exceptions/ExceptionStatsBar.jsx
export default function ExceptionStatsBar({ summary, metadataTotal, metadataQueueCounts, viewMode }) {
if (viewMode === 'wizard') {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MetricCard label="待补全" value={metadataTotal || 0} tone="indigo" />
<MetricCard label="缺标签" value={metadataQueueCounts?.missing_tags ?? 0} tone="amber" />
<MetricCard label="匹配失败" value={metadataQueueCounts?.match_failed ?? 0} tone="rose" />
<MetricCard label="低分匹配" value={metadataQueueCounts?.low_score ?? 0} tone="emerald" />
</div>
);
}
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MetricCard label="全部开放" value={summary?.total ?? '-'} tone="indigo" />
<MetricCard label="重复" value={summary?.counts_by_type?.duplicates ?? '-'} tone="amber" />
<MetricCard label="匹配失败" value={summary?.counts_by_type?.match_failed ?? '-'} tone="rose" />
<MetricCard label="入库失败" value={summary?.counts_by_type?.organize_failed ?? '-'} tone="emerald" />
</div>
);
}
function MetricCard({ label, value, tone }) {
const colors = {
indigo: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200',
amber: 'border-amber-500/30 bg-amber-500/10 text-amber-200',
rose: 'border-rose-500/30 bg-rose-500/10 text-rose-200',
emerald: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
};
return (
<div className={`rounded-2xl border ${colors[tone] || colors.indigo} p-4 text-center transition hover:-translate-y-0.5`}>
<div className="text-2xl font-bold">{value}</div>
<div className="mt-1 text-xs opacity-70">{label}</div>
</div>
);
}
@@ -0,0 +1,37 @@
// frontend/src/components/exceptions/ExceptionTypeNav.jsx
import { EXCEPTION_FILTERS, RESOLUTION_FILTERS, chipClass, getFilterCount } from '../../utils/exceptions';
export default function ExceptionTypeNav({
activeFilter, onFilterChange,
resolutionFilter, onResolutionChange,
summary
}) {
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{EXCEPTION_FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => onFilterChange(filter.id)}
className={chipClass(activeFilter === filter.id)}
>
{filter.name}
<span className="ml-2 font-mono text-[11px]">{getFilterCount(summary, filter.id)}</span>
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.2em] text-slate-500">处理状态</span>
{RESOLUTION_FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => onResolutionChange(filter.id)}
className={chipClass(resolutionFilter === filter.id)}
>
{filter.name}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,191 @@
// frontend/src/components/exceptions/ExceptionWizard.jsx
import { Music2, Wrench, Check } from 'lucide-react';
import { WIZARD_STEPS, actionButtonClass, getMissingRequiredFields } from '../../utils/exceptions';
import ExceptionStatsBar from './ExceptionStatsBar';
import StepSelect from './steps/StepSelect';
import StepListen from './steps/StepListen';
import StepMatch from './steps/StepMatch';
import StepEdit from './steps/StepEdit';
import StepConfirm from './steps/StepConfirm';
import RepairTaskPanel from './RepairTaskPanel';
export default function ExceptionWizard({
summary, metadataQueue, metadataTotal, metadataQueueCounts,
isMetadataQueueLoading, metadataQueueError,
selectedExceptionId, onSelectItem,
detailRecord, isDetailLoading, detailError,
wizardStep, onSetWizardStep,
selectedAction, actionParams,
providerMode, setProviderMode, providers, setProviders,
previewState, executionState,
repairTask, repairLogs,
onFocusAction, onPreview, onExecute,
onUpdateMetadata, onSwitchToAdvanced
}) {
const draft = actionParams.metadata_patch && Object.keys(actionParams.metadata_patch).length > 0
? actionParams.metadata_patch
: detailRecord?.effective_metadata || {};
const missingFields = getMissingRequiredFields(draft);
const canIngest = missingFields.length === 0 && (detailRecord?.available_actions || []).includes('save_and_organize');
const renderStep = () => {
switch (wizardStep) {
case 'select':
return (
<StepSelect
metadataQueue={metadataQueue}
selectedExceptionId={selectedExceptionId}
isLoading={isMetadataQueueLoading}
error={metadataQueueError}
onSelectItem={(id) => { onSelectItem(id); onSetWizardStep('listen'); }}
/>
);
case 'listen':
return <StepListen detailRecord={detailRecord} isLoading={isDetailLoading} />;
case 'match':
return (
<StepMatch
detailRecord={detailRecord} isLoading={isDetailLoading}
providerMode={providerMode} setProviderMode={setProviderMode}
providers={providers} setProviders={setProviders}
previewState={previewState} executionState={executionState}
onPreview={onPreview} onExecute={onExecute}
/>
);
case 'edit':
return (
<StepEdit
detailRecord={detailRecord} isLoading={isDetailLoading}
metadataPatch={actionParams.metadata_patch || {}}
onUpdateMetadata={onUpdateMetadata}
canIngest={canIngest}
previewState={previewState} onPreview={onPreview}
/>
);
case 'confirm':
return (
<StepConfirm
detailRecord={detailRecord} isLoading={isDetailLoading}
selectedAction={selectedAction}
previewState={previewState} executionState={executionState}
repairTask={repairTask} repairLogs={repairLogs}
onPreview={onPreview} onExecute={onExecute}
canIngest={canIngest}
/>
);
default:
return null;
}
};
return (
<div className="flex min-h-[calc(100vh-120px)] flex-col gap-6 py-6">
<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>
<p className="text-xs uppercase tracking-[0.28em] text-indigo-300/70">单曲补全向导</p>
<h2 className="-mt-1 flex items-center gap-3 text-2xl font-semibold text-white">
<Music2 className="h-6 w-6 text-indigo-300" />
元数据异常补全
</h2>
<p className="mt-2 max-w-3xl text-sm text-slate-400">
只处理元数据缺失匹配失败和低分匹配重复文件转码失败和入库失败保留在高级处理中
</p>
</div>
<ExceptionStatsBar
summary={summary}
metadataTotal={metadataTotal}
metadataQueueCounts={metadataQueueCounts}
viewMode="wizard"
/>
</div>
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<WizardProgressBar currentStep={wizardStep} onSelectStep={onSetWizardStep} />
<button onClick={onSwitchToAdvanced} className={actionButtonClass(true)}>
<Wrench className="h-3.5 w-3.5" />
高级处理
</button>
</div>
</section>
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[minmax(320px,0.42fr)_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>
<h3 className="mt-2 text-lg font-semibold text-white">{metadataQueue.length} 个待处理</h3>
</div>
<div className="max-h-[calc(100vh-330px)] min-h-[320px] overflow-auto p-3">
{wizardStep === 'select' ? renderStep() : (
<StepSelect
metadataQueue={metadataQueue}
selectedExceptionId={selectedExceptionId}
isLoading={isMetadataQueueLoading}
error={metadataQueueError}
onSelectItem={(id) => { onSelectItem(id); onSetWizardStep('listen'); }}
/>
)}
</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)]">
{!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" />
<p className="text-sm">从左侧队列选择一个文件开始处理</p>
</div>
) : 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>
)}
</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>
);
}
function WizardProgressBar({ currentStep, onSelectStep }) {
const stepIndex = WIZARD_STEPS.findIndex((s) => s.id === currentStep);
return (
<div className="flex items-center gap-2">
{WIZARD_STEPS.map((step, index) => {
const isCompleted = index < stepIndex;
const isCurrent = index === stepIndex;
return (
<div key={step.id} className="flex items-center gap-2">
<button
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'
: isCompleted
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-200'
: 'border border-slate-700 text-slate-500 hover:border-slate-600'
}`}
>
{isCompleted ? (
<Check className="h-3 w-3" />
) : (
<span className="flex h-4 w-4 items-center justify-center rounded-full text-[10px]">{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>
);
})}
</div>
);
}
@@ -0,0 +1,42 @@
// frontend/src/components/exceptions/RepairTaskPanel.jsx
import { LoaderCircle } from 'lucide-react';
import { isTerminalRepairStatus } from '../../utils/exceptions';
export default function RepairTaskPanel({ repairTask, repairLogs, executionState }) {
if (!repairTask && !executionState) return null;
const statusLabel = {
submitting: '正在提交...', accepted: '已提交', running: '执行中',
completed: '已完成', failed: '失败'
};
const statusColor = {
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'
};
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>
{repairTask && (
<span className={`ml-auto rounded-full px-2 py-0.5 text-[11px] ${statusColor[repairTask.status] || ''}`}>
{statusLabel[repairTask.status] || repairTask.status}
</span>
)}
</div>
{repairTask && (
<div className="mt-2 text-xs text-slate-500">
任务号: {repairTask.task_id}
{repairTask.error_message && (
<span className="ml-2 text-rose-400">{repairTask.error_message}</span>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,171 @@
// frontend/src/components/exceptions/steps/StepConfirm.jsx
import { ShieldAlert, Sparkles, LoaderCircle } from 'lucide-react';
import { actionButtonClass, riskClass, formatSeconds, isTerminalRepairStatus } from '../../../utils/exceptions';
function InfoField({ label, value, mono = false }) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-slate-500">{label}</div>
<div className={`mt-1 text-sm text-slate-100 ${mono ? 'break-all font-mono text-[11px]' : ''}`}>{value || '--'}</div>
</div>
);
}
export default function StepConfirm({
detailRecord, isLoading,
selectedAction,
previewState, executionState,
repairTask, repairLogs,
onPreview, onExecute, canIngest
}) {
if (!detailRecord && !isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center text-slate-500">
<p className="text-sm">请先选择一首歌曲</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center">
<LoaderCircle className="h-8 w-8 animate-spin text-indigo-300/60" />
</div>
);
}
const finalPreviewItem = previewState.action === 'save_and_organize' && previewState.payload
? previewState.payload.items?.find((item) => item.exception_id === detailRecord.exception_id) || null
: null;
const finalPreview = finalPreviewItem?.final_library_preview || null;
const ap = detailRecord.audio_props_json || {};
return (
<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>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-300">
<InfoField label="文件名" value={detailRecord.filename} mono />
<InfoField label="时长" value={formatSeconds(ap.duration_seconds)} />
<InfoField label="匹配来源" value={detailRecord.match_source || '--'} />
<InfoField label="操作" value={
{ save_and_organize: '加入音乐库', edit_metadata: '保存草稿',
ignore_exception: '永久忽略', delete_file: '删除文件',
retry_match: '一键匹配'
}[selectedAction] || selectedAction
} />
</div>
</div>
{finalPreview && (
<div className="rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
<div className="flex items-center justify-between gap-3">
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
<ShieldAlert className="h-4 w-4 text-amber-300" />入库确认
</h5>
<span className={`rounded-full px-2 py-1 text-[11px] ${riskClass(previewState.payload?.risk_level)}`}>
风险 {previewState.payload?.risk_level}
</span>
</div>
<div className="mt-3 grid gap-3 text-xs text-slate-300">
<InfoField label="目标路径" value={finalPreview.target_relative_path} mono />
<InfoField label="完整路径" value={finalPreview.target_file_path} mono />
</div>
</div>
)}
{(previewState.action === 'ignore_exception' || previewState.action === 'delete_file') && previewState.payload && !previewState.loading && (
<div className="rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
<ShieldAlert className="h-4 w-4 text-amber-300" />操作预览
</h5>
<span className={`rounded-full px-2 py-1 text-[11px] ${riskClass(previewState.payload?.risk_level)}`}>
风险 {previewState.payload?.risk_level}
</span>
<div className="mt-3 space-y-2">
{previewState.payload.items?.map((item) => (
<div key={item.exception_id} className="rounded-xl border border-amber-900/30 bg-slate-950/60 p-3 text-xs text-slate-300">
<div className="font-medium text-slate-100">{item.filename}</div>
{item.planned_operations?.map((op, i) => (
<div key={`${op.type}-${i}`} className="mt-1 text-slate-400">{op.description}</div>
))}
</div>
))}
</div>
</div>
)}
{previewState.error && previewState.action ? (
<p className="text-xs text-rose-300">{previewState.error}</p>
) : null}
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<div className="flex flex-wrap gap-2">
<button onClick={() => onPreview('save_and_organize')}
className={actionButtonClass(selectedAction === 'save_and_organize')}>
<Sparkles className="h-3.5 w-3.5" />预览入库
</button>
<button onClick={() => onExecute('save_and_organize')}
disabled={!canIngest}
className={`rounded-xl px-3 py-2 text-sm font-medium transition ${
canIngest ? 'bg-indigo-500 text-white hover:bg-indigo-400' : 'cursor-not-allowed bg-slate-800 text-slate-500'
}`}>
确认入库
</button>
<button onClick={() => onExecute('edit_metadata')}
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
保存草稿
</button>
<button onClick={() => onPreview('ignore_exception')}
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
预览忽略
</button>
<button onClick={() => onExecute('ignore_exception')}
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
确认忽略
</button>
<button onClick={() => onPreview('delete_file')}
className="rounded-xl bg-rose-100 px-3 py-2 text-sm font-medium text-rose-950">
预览删除
</button>
<button onClick={() => onExecute('delete_file')}
className="rounded-xl bg-rose-500 px-3 py-2 text-sm font-medium text-white">
删除文件
</button>
</div>
{executionState?.status && (
<div className="mt-4 rounded-xl border border-indigo-900/40 bg-indigo-950/20 p-3 text-xs text-indigo-100/85">
<div className="font-medium">
{executionState.status === 'submitting' ? '正在提交执行请求'
: executionState.status === 'accepted' ? '任务已提交'
: executionState.status === 'running' ? '任务执行中'
: executionState.status === 'completed' ? '执行完成'
: executionState.status === 'failed' ? '执行失败' : ''}
</div>
{executionState.repairTaskId && (
<div className="mt-1 text-slate-400">任务号 {executionState.repairTaskId}</div>
)}
{executionState.error && (
<div className="mt-1 text-rose-200">{executionState.error}</div>
)}
</div>
)}
{repairTask && repairLogs.length > 0 && executionState?.repairTaskId === repairTask.task_id && (
<div className="mt-4 rounded-xl border border-slate-800 bg-slate-950/60 p-3">
<div className="flex items-center gap-2 text-xs text-slate-400">
<LoaderCircle className={`h-3 w-3 ${!isTerminalRepairStatus(repairTask.status) ? 'animate-spin' : ''}`} />
任务日志 ({repairTask.status})
</div>
<div className="mt-2 max-h-[160px] overflow-auto space-y-1 font-mono text-[11px] text-slate-500">
{repairLogs.map((log, i) => (
<div key={i}>{log.message || log.stage || '--'}</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,155 @@
// frontend/src/components/exceptions/steps/StepEdit.jsx
import { LoaderCircle, Sparkles } from 'lucide-react';
import { METADATA_FIELDS, REQUIRED_FIELDS, inputClass, formatMetadataValue } from '../../../utils/exceptions';
function FinalMetadataPreview({ previewState, selectedId }) {
const finalItem = previewState.payload?.items?.find((item) => item.exception_id === selectedId) || null;
const finalPreview = finalItem?.final_library_preview || null;
if (!finalPreview) return null;
return (
<div className="mt-4 space-y-3">
<div className="rounded-xl border border-amber-900/40 bg-amber-950/20 p-4">
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
<Sparkles className="h-4 w-4 text-amber-300" />入库确认
</h5>
<div className="mt-3 grid gap-3 text-xs text-slate-300">
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-slate-500">目标相对路径</div>
<div className="mt-1 break-all font-mono text-[11px] text-slate-100">{finalPreview.target_relative_path}</div>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-slate-500">完整目标文件路径</div>
<div className="mt-1 break-all font-mono text-[11px] text-slate-100">{finalPreview.target_file_path}</div>
</div>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-950/60">
<table className="min-w-[620px] w-full border-collapse text-left text-xs">
<thead className="bg-slate-900/70 text-[11px] uppercase tracking-[0.14em] text-slate-500">
<tr>
<th className="w-40 px-3 py-3 font-medium">字段</th>
<th className="px-3 py-3 font-medium">最终值</th>
<th className="w-36 px-3 py-3 font-medium">来源</th>
</tr>
</thead>
<tbody>
{METADATA_FIELDS.map((field) => (
<tr key={field} className="border-t border-slate-800/80">
<td className="px-3 py-3 font-mono text-[11px] text-indigo-100">{field}</td>
<td className="px-3 py-3 whitespace-pre-wrap break-all text-slate-100">
{formatMetadataValue(finalPreview.metadata?.[field])}
</td>
<td className="px-3 py-3 text-slate-400">{finalPreview.metadata_sources?.[field] || '--'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default function StepEdit({
detailRecord, isLoading,
metadataPatch, onUpdateMetadata,
canIngest,
previewState, onPreview
}) {
if (!detailRecord && !isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center text-slate-500">
<p className="text-sm">请先选择一首歌曲</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center">
<LoaderCircle className="h-8 w-8 animate-spin text-indigo-300/60" />
</div>
);
}
const draft = metadataPatch && Object.keys(metadataPatch).length > 0
? metadataPatch
: detailRecord.effective_metadata || {};
return (
<div className="space-y-4">
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<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>
<span className={`rounded-full border px-2.5 py-1 text-[11px] ${
canIngest ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
: 'border-rose-500/30 bg-rose-500/10 text-rose-200'
}`}>
{canIngest ? '可入库' : '缺少必填'}
</span>
</div>
{detailRecord.album_artist_reason && (
<div className="mt-4 rounded-xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
{detailRecord.album_artist_reason}
</div>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
<input className={inputClass()} value={draft.title || ''} onChange={(e) => onUpdateMetadata('title', e.target.value)} placeholder="标题 *" />
<input className={inputClass()} value={draft.artist || ''} onChange={(e) => onUpdateMetadata('artist', e.target.value)} placeholder="艺术家 *" />
<input className={inputClass()} value={draft.album_artist || ''} onChange={(e) => onUpdateMetadata('album_artist', e.target.value)} placeholder="专辑艺术家 *" />
<input className={inputClass()} value={draft.album || ''} onChange={(e) => onUpdateMetadata('album', e.target.value)} placeholder="专辑" />
<input className={inputClass()} value={draft.track_number ?? ''} onChange={(e) => onUpdateMetadata('track_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="曲目号" />
<input className={inputClass()} value={draft.disc_number ?? ''} onChange={(e) => onUpdateMetadata('disc_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="碟号" />
<input className={inputClass()} value={draft.year ?? ''} onChange={(e) => onUpdateMetadata('year', e.target.value === '' ? null : Number(e.target.value))} placeholder="年份" />
</div>
<textarea className={`${inputClass()} mt-2 min-h-[96px] resize-y`}
value={draft.lyrics || ''} onChange={(e) => onUpdateMetadata('lyrics', e.target.value)} placeholder="歌词" />
<div className="mt-4 grid grid-cols-3 gap-2 text-xs">
{REQUIRED_FIELDS.map((field) => {
const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' };
const present = String(draft[field] || '').trim();
return (
<div key={field} className={`rounded-xl border px-3 py-2 ${
present ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100'
: 'border-rose-500/30 bg-rose-500/10 text-rose-100'
}`}>{labels[field]}</div>
);
})}
</div>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<h4 className="text-sm font-medium text-white">入库预览</h4>
<p className="mt-2 text-xs leading-5 text-slate-400">点击下方按钮生成后端计算的最终元数据和入库路径</p>
<button onClick={() => onPreview('save_and_organize')}
disabled={!canIngest}
className={`mt-4 inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium transition ${
canIngest ? 'bg-slate-100 text-slate-900' : 'cursor-not-allowed bg-slate-800 text-slate-500'
}`}>
<Sparkles className="h-3.5 w-3.5" />刷新入库预览
</button>
{previewState.action === 'save_and_organize' && previewState.loading && (
<div className="mt-4 flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-400">
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成入库确认...
</div>
)}
{previewState.error && previewState.action === 'save_and_organize' && (
<p className="mt-3 text-xs text-rose-300">{previewState.error}</p>
)}
{previewState.action === 'save_and_organize' && previewState.payload && !previewState.loading && (
<FinalMetadataPreview previewState={previewState} selectedId={detailRecord.exception_id} />
)}
</div>
</div>
);
}
@@ -0,0 +1,102 @@
// frontend/src/components/exceptions/steps/StepListen.jsx
import { useState, useRef } from 'react';
import { Headphones, Play, Pause, LoaderCircle } from 'lucide-react';
import { buildExceptionAudioUrl } from '../../../api/exceptions';
import { formatSeconds } from '../../../utils/exceptions';
function InfoField({ label, value, mono = false }) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-slate-500">{label}</div>
<div className={`mt-1 text-sm text-slate-100 ${mono ? 'break-all font-mono text-[11px]' : ''}`}>{value || '--'}</div>
</div>
);
}
export default function StepListen({ detailRecord, isLoading }) {
const audioRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [audioError, setAudioError] = useState('');
if (!detailRecord && !isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center text-slate-500">
<p className="text-sm">请先选择一首歌曲</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center">
<LoaderCircle className="h-8 w-8 animate-spin text-indigo-300/60" />
</div>
);
}
const audioUrl = buildExceptionAudioUrl(detailRecord.exception_id);
const ap = detailRecord.audio_props_json || {};
const togglePlay = () => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play().catch(() => setAudioError('播放器启动失败'));
} else {
audioRef.current.pause();
}
};
return (
<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">
<InfoField label="格式" value={ap.format || '--'} />
<InfoField label="编码" value={ap.codec || '--'} />
<InfoField label="采样率" value={ap.sample_rate ? `${ap.sample_rate} Hz` : '--'} />
<InfoField label="比特率" value={ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'} />
<InfoField label="位深" value={ap.bit_depth ? `${ap.bit_depth} bit` : '--'} />
<InfoField label="时长" value={formatSeconds(ap.duration_seconds)} />
</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>
</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"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
</button>
</div>
<audio ref={audioRef} src={audioUrl} preload="metadata"
onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
onError={() => setAudioError('音频文件不可用或已丢失')}
className="hidden"
/>
<div className="mt-4">
<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"
/>
<div className="mt-2 flex items-center justify-between font-mono text-[11px] text-slate-500">
<span>{formatSeconds(currentTime)}</span><span>{formatSeconds(duration)}</span>
</div>
</div>
{audioError ? <p className="mt-3 text-xs text-rose-300">{audioError}</p> : null}
</div>
</div>
);
}
@@ -0,0 +1,160 @@
// frontend/src/components/exceptions/steps/StepMatch.jsx
import { Search, LoaderCircle, ShieldAlert } from 'lucide-react';
import { PROVIDER_MODES, chipClass, actionButtonClass, formatConfidence, providerLabel } from '../../../utils/exceptions';
function InfoField({ label, value }) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-slate-500">{label}</div>
<div className="mt-1 text-sm text-slate-100">{value || '--'}</div>
</div>
);
}
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,
onPreview, onExecute
}) {
if (!detailRecord && !isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center text-slate-500">
<p className="text-sm">请先选择一首歌曲</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center">
<LoaderCircle className="h-8 w-8 animate-spin text-indigo-300/60" />
</div>
);
}
const candidates = detailRecord.match_candidates_json || [];
return (
<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">
<InfoField label="匹配来源" value={detailRecord.match_source || '--'} />
<InfoField label="匹配分数" value={formatConfidence(detailRecord.match_confidence)} />
</div>
<div className="mt-3 rounded-xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
{detailRecord.display_reason || '-'}
</div>
</div>
{candidates.length > 0 && (
<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) => (
<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">
{[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)}%
</span>
)}
</div>
{candidate.source && (
<div className="mt-2 text-[11px] text-slate-500">{providerLabel(candidate.source)}</div>
)}
</div>
))}
</div>
</div>
)}
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<h4 className="text-sm font-medium text-white">重新匹配</h4>
<p className="mt-2 text-xs leading-5 text-slate-400">选择匹配来源后执行重新匹配结果会自动填充到下方编辑区</p>
<div className="mt-4 flex flex-wrap gap-2">
{PROVIDER_MODES.map((mode) => {
const active = providerMode === mode.id;
return (
<button key={mode.id} onClick={() => { setProviderMode(mode.id); setProviders(mode.providers); }}
className={chipClass(active)}>
{mode.label}
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button onClick={() => onPreview('retry_match')} className={actionButtonClass(true)}>
<Search className="h-3.5 w-3.5" />预览匹配
</button>
<button onClick={() => onExecute('retry_match')}
className="rounded-xl bg-indigo-500 px-3 py-2 text-sm font-medium text-white">
执行匹配
</button>
</div>
{previewState.action === 'retry_match' && previewState.loading && (
<div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成匹配预览...
</div>
)}
{previewState.action === 'retry_match' && previewState.payload && !previewState.loading && (
<div className="mt-4 rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
<ShieldAlert className="h-4 w-4 text-amber-300" />匹配预览结果
</h5>
<div className="mt-3 space-y-2">
{previewState.payload.items?.map((item) => (
<div key={item.exception_id} className="rounded-xl border border-amber-900/30 bg-slate-950/60 p-3 text-xs text-slate-300">
<div className="font-medium text-slate-100">{item.filename}</div>
<div className="mt-2 space-y-1">
{item.planned_operations?.map((op, i) => (
<div key={`${op.type}-${i}`} className="text-slate-400">{op.description}</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{previewState.error && previewState.action === 'retry_match' && (
<p className="mt-3 text-xs text-rose-300">{previewState.error}</p>
)}
{executionState?.action === 'retry_match' && (
<div className="mt-3"><StatusBadge status={executionState.status} /></div>
)}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
// frontend/src/components/exceptions/steps/StepSelect.jsx
import { Music2, LoaderCircle } from 'lucide-react';
export default function StepSelect({
metadataQueue, selectedExceptionId, isLoading, error,
onSelectItem
}) {
if (isLoading) {
return (
<div className="flex h-full min-h-[420px] items-center justify-center">
<LoaderCircle className="h-8 w-8 animate-spin text-indigo-300/60" />
</div>
);
}
if (error) {
return (
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">
{error}
</div>
);
}
if (!metadataQueue.length) {
return (
<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" />
<p className="text-sm">没有待处理的异常</p>
</div>
);
}
return (
<div className="space-y-2">
{metadataQueue.map((item) => {
const selected = selectedExceptionId === item.exception_id;
const ap = item.audio_props_json || {};
return (
<button
key={item.exception_id}
onClick={() => onSelectItem(item.exception_id)}
className={`w-full rounded-2xl border p-4 text-left transition ${
selected
? 'border-indigo-400/60 bg-indigo-500/10 shadow-[0_0_0_1px_rgba(99,102,241,0.08)]'
: 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:bg-slate-900'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-slate-100">{item.display_title}</div>
<div className="mt-1 truncate font-mono text-[11px] text-indigo-300/80">{item.filename}</div>
</div>
<span className="shrink-0 rounded-full border border-slate-700 bg-slate-950 px-2.5 py-1 text-[11px] text-slate-300">
{item.type_label}
</span>
</div>
<div className="mt-3 line-clamp-2 text-xs leading-5 text-slate-400">{item.display_reason}</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-slate-500">
<span>{ap.duration_seconds ? `${Math.floor(ap.duration_seconds / 60)}:${String(Math.floor(ap.duration_seconds % 60)).padStart(2, '0')}` : '--:--'}</span>
<span className="text-right">{ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'}</span>
</div>
</button>
);
})}
</div>
);
}