Files
MusicWorkshop/frontend/src/components/exceptions/steps/StepConfirm.jsx
T
liumangmang ad9501fb11 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>
2026-05-07 21:02:10 +08:00

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>
);
}