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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user