ad9501fb11
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>
172 lines
7.9 KiB
React
172 lines
7.9 KiB
React
// 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>
|
|
);
|
|
}
|