From ad9501fb11110ff18ed21eb0f7eb89c060885e49 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Thu, 7 May 2026 21:02:10 +0800 Subject: [PATCH] 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 --- .../exceptions/ExceptionStatsBar.jsx | 37 ++++ .../exceptions/ExceptionTypeNav.jsx | 37 ++++ .../components/exceptions/ExceptionWizard.jsx | 191 ++++++++++++++++++ .../components/exceptions/RepairTaskPanel.jsx | 42 ++++ .../exceptions/steps/StepConfirm.jsx | 171 ++++++++++++++++ .../components/exceptions/steps/StepEdit.jsx | 155 ++++++++++++++ .../exceptions/steps/StepListen.jsx | 102 ++++++++++ .../components/exceptions/steps/StepMatch.jsx | 160 +++++++++++++++ .../exceptions/steps/StepSelect.jsx | 67 ++++++ 9 files changed, 962 insertions(+) create mode 100644 frontend/src/components/exceptions/ExceptionStatsBar.jsx create mode 100644 frontend/src/components/exceptions/ExceptionTypeNav.jsx create mode 100644 frontend/src/components/exceptions/ExceptionWizard.jsx create mode 100644 frontend/src/components/exceptions/RepairTaskPanel.jsx create mode 100644 frontend/src/components/exceptions/steps/StepConfirm.jsx create mode 100644 frontend/src/components/exceptions/steps/StepEdit.jsx create mode 100644 frontend/src/components/exceptions/steps/StepListen.jsx create mode 100644 frontend/src/components/exceptions/steps/StepMatch.jsx create mode 100644 frontend/src/components/exceptions/steps/StepSelect.jsx diff --git a/frontend/src/components/exceptions/ExceptionStatsBar.jsx b/frontend/src/components/exceptions/ExceptionStatsBar.jsx new file mode 100644 index 0000000..cdd5129 --- /dev/null +++ b/frontend/src/components/exceptions/ExceptionStatsBar.jsx @@ -0,0 +1,37 @@ +// frontend/src/components/exceptions/ExceptionStatsBar.jsx +export default function ExceptionStatsBar({ summary, metadataTotal, metadataQueueCounts, viewMode }) { + if (viewMode === 'wizard') { + return ( +
+ + + + +
+ ); + } + + return ( +
+ + + + +
+ ); +} + +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 ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/frontend/src/components/exceptions/ExceptionTypeNav.jsx b/frontend/src/components/exceptions/ExceptionTypeNav.jsx new file mode 100644 index 0000000..2750334 --- /dev/null +++ b/frontend/src/components/exceptions/ExceptionTypeNav.jsx @@ -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 ( +
+
+ {EXCEPTION_FILTERS.map((filter) => ( + + ))} +
+
+ 处理状态 + {RESOLUTION_FILTERS.map((filter) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/exceptions/ExceptionWizard.jsx b/frontend/src/components/exceptions/ExceptionWizard.jsx new file mode 100644 index 0000000..c01a096 --- /dev/null +++ b/frontend/src/components/exceptions/ExceptionWizard.jsx @@ -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 ( + { onSelectItem(id); onSetWizardStep('listen'); }} + /> + ); + case 'listen': + return ; + case 'match': + return ( + + ); + case 'edit': + return ( + + ); + case 'confirm': + return ( + + ); + default: + return null; + } + }; + + return ( +
+
+
+
+

单曲补全向导

+

+ + 元数据异常补全 +

+

+ 只处理元数据缺失、匹配失败和低分匹配。重复文件、转码失败和入库失败保留在高级处理中。 +

+
+ +
+
+ + +
+
+ +
+
+
+

待处理队列

+

{metadataQueue.length} 个待处理

+
+
+ {wizardStep === 'select' ? renderStep() : ( + { onSelectItem(id); onSetWizardStep('listen'); }} + /> + )} +
+
+ +
+ {!detailRecord && !isDetailLoading && wizardStep !== 'select' ? ( +
+ +

从左侧队列选择一个文件开始处理

+
+ ) : detailError ? ( +
{detailError}
+ ) : ( +
+ {wizardStep !== 'select' && renderStep()} +
+ )} +
+
+ + {repairTask && ( +
+ +
+ )} +
+ ); +} + +function WizardProgressBar({ currentStep, onSelectStep }) { + const stepIndex = WIZARD_STEPS.findIndex((s) => s.id === currentStep); + + return ( +
+ {WIZARD_STEPS.map((step, index) => { + const isCompleted = index < stepIndex; + const isCurrent = index === stepIndex; + + return ( +
+ + {index < WIZARD_STEPS.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/exceptions/RepairTaskPanel.jsx b/frontend/src/components/exceptions/RepairTaskPanel.jsx new file mode 100644 index 0000000..f074328 --- /dev/null +++ b/frontend/src/components/exceptions/RepairTaskPanel.jsx @@ -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 ( +
+
+ + 修复任务 + {repairTask && ( + + {statusLabel[repairTask.status] || repairTask.status} + + )} +
+ {repairTask && ( +
+ 任务号: {repairTask.task_id} + {repairTask.error_message && ( + {repairTask.error_message} + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/exceptions/steps/StepConfirm.jsx b/frontend/src/components/exceptions/steps/StepConfirm.jsx new file mode 100644 index 0000000..fba70a3 --- /dev/null +++ b/frontend/src/components/exceptions/steps/StepConfirm.jsx @@ -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 ( +
+
{label}
+
{value || '--'}
+
+ ); +} + +export default function StepConfirm({ + detailRecord, isLoading, + selectedAction, + previewState, executionState, + repairTask, repairLogs, + onPreview, onExecute, canIngest +}) { + if (!detailRecord && !isLoading) { + return ( +
+

请先选择一首歌曲

+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + 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 ( +
+
+

{detailRecord.display_title || '-'}

+
+ + + + +
+
+ + {finalPreview && ( +
+
+
+ 入库确认 +
+ + 风险 {previewState.payload?.risk_level} + +
+
+ + +
+
+ )} + + {(previewState.action === 'ignore_exception' || previewState.action === 'delete_file') && previewState.payload && !previewState.loading && ( +
+
+ 操作预览 +
+ + 风险 {previewState.payload?.risk_level} + +
+ {previewState.payload.items?.map((item) => ( +
+
{item.filename}
+ {item.planned_operations?.map((op, i) => ( +
{op.description}
+ ))} +
+ ))} +
+
+ )} + + {previewState.error && previewState.action ? ( +

{previewState.error}

+ ) : null} + +
+
+ + + + + + + +
+ + {executionState?.status && ( +
+
+ {executionState.status === 'submitting' ? '正在提交执行请求' + : executionState.status === 'accepted' ? '任务已提交' + : executionState.status === 'running' ? '任务执行中' + : executionState.status === 'completed' ? '执行完成' + : executionState.status === 'failed' ? '执行失败' : ''} +
+ {executionState.repairTaskId && ( +
任务号 {executionState.repairTaskId}
+ )} + {executionState.error && ( +
{executionState.error}
+ )} +
+ )} + + {repairTask && repairLogs.length > 0 && executionState?.repairTaskId === repairTask.task_id && ( +
+
+ + 任务日志 ({repairTask.status}) +
+
+ {repairLogs.map((log, i) => ( +
{log.message || log.stage || '--'}
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/exceptions/steps/StepEdit.jsx b/frontend/src/components/exceptions/steps/StepEdit.jsx new file mode 100644 index 0000000..c49493e --- /dev/null +++ b/frontend/src/components/exceptions/steps/StepEdit.jsx @@ -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 ( +
+
+
+ 入库确认 +
+
+
+
目标相对路径
+
{finalPreview.target_relative_path}
+
+
+
完整目标文件路径
+
{finalPreview.target_file_path}
+
+
+
+ +
+ + + + + + + + + + {METADATA_FIELDS.map((field) => ( + + + + + + ))} + +
字段最终值来源
{field} + {formatMetadataValue(finalPreview.metadata?.[field])} + {finalPreview.metadata_sources?.[field] || '--'}
+
+
+ ); +} + +export default function StepEdit({ + detailRecord, isLoading, + metadataPatch, onUpdateMetadata, + canIngest, + previewState, onPreview +}) { + if (!detailRecord && !isLoading) { + return ( +
+

请先选择一首歌曲

+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const draft = metadataPatch && Object.keys(metadataPatch).length > 0 + ? metadataPatch + : detailRecord.effective_metadata || {}; + + return ( +
+
+
+
+

{detailRecord.display_title || '-'}

+

{detailRecord.filename}

+
+ + {canIngest ? '可入库' : '缺少必填'} + +
+ + {detailRecord.album_artist_reason && ( +
+ {detailRecord.album_artist_reason} +
+ )} + +
+ onUpdateMetadata('title', e.target.value)} placeholder="标题 *" /> + onUpdateMetadata('artist', e.target.value)} placeholder="艺术家 *" /> + onUpdateMetadata('album_artist', e.target.value)} placeholder="专辑艺术家 *" /> + onUpdateMetadata('album', e.target.value)} placeholder="专辑" /> + onUpdateMetadata('track_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="曲目号" /> + onUpdateMetadata('disc_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="碟号" /> + onUpdateMetadata('year', e.target.value === '' ? null : Number(e.target.value))} placeholder="年份" /> +
+