# 异常中心页面优化 — 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将 ExceptionPage.jsx 从 800+ 行单体组件重构为 10 个聚焦组件 + 5 个自定义 hooks,并应用深色专业主题样式。 **Architecture:** 按 Phase 分层推进 — 先提取纯逻辑层 hooks(不改变 UI),再拆分组件,最后应用样式和动画。数据通过 props 向下流动,回调向上通知,不使用 Context。共享工具函数和常量提取到 `utils/exceptions.js`。 **Tech Stack:** React 18 + Vite + Tailwind CSS + lucide-react icons,无后端改动 **Spec:** [2026-05-07-exception-center-redesign.md](../specs/2026-05-07-exception-center-redesign.md) --- ## 文件结构总览 ``` frontend/src/ ├── pages/ │ └── ExceptionPage.jsx # Modify: 精简为容器组件 (~100行) ├── components/exceptions/ # Create directory │ ├── ExceptionStatsBar.jsx # Create │ ├── ExceptionTypeNav.jsx # Create │ ├── ExceptionWizard.jsx # Create │ ├── ExceptionListView.jsx # Create (原高级视图内容) │ ├── ExceptionListTable.jsx # Create (表格部分) │ ├── steps/ │ │ ├── StepSelect.jsx # Create │ │ ├── StepListen.jsx # Create │ │ ├── StepMatch.jsx # Create │ │ ├── StepEdit.jsx # Create │ │ └── StepConfirm.jsx # Create │ ├── RepairTaskPanel.jsx # Create │ └── ActionPreviewModal.jsx # Create ├── hooks/ │ ├── useExceptionSummary.js # Create │ ├── useExceptionList.js # Create │ ├── useExceptionDetail.js # Create │ ├── useRepairTask.js # Create │ └── useWizardState.js # Create └── utils/ └── exceptions.js # Create (共享常量和工具函数) ``` --- ## Phase 1: 共享工具层 ### Task 1.1: 提取共享常量和工具函数到 utils/exceptions.js **Files:** - Create: `frontend/src/utils/exceptions.js` - Modify: `frontend/src/pages/ExceptionPage.jsx` (import from utils instead of inline) - [ ] **Step 1: 创建 utils/exceptions.js 包含所有共享常量** ```javascript // frontend/src/utils/exceptions.js export const EXCEPTION_FILTERS = [ { id: 'all', name: '全部异常' }, { id: 'missing_tags', name: '元数据缺失' }, { id: 'duplicates', name: '文件重复' }, { id: 'match_failed', name: '匹配失败' }, { id: 'low_score', name: '匹配分过低' }, { id: 'convert_failed', name: '转码失败' }, { id: 'organize_failed', name: '入库失败' } ]; export const RESOLUTION_FILTERS = [ { id: 'open', name: '开放中' }, { id: 'resolved', name: '已解决' }, { id: 'ignored', name: '已忽略' }, { id: 'all', name: '全部' } ]; export const ACTION_LABELS = { retry_match: '一键匹配', select_match_candidate: '确认候选', edit_metadata: '保存草稿', save_and_organize: '加入音乐库', keep_existing: '忽略并删除新版', replace_existing: '覆盖替换旧版', keep_both_with_rename: '重命名新版并保留双版本', retry_preprocess: '重跑预处理', move_to_review_trash: '移入 Review Trash', retry_organize: '重试入库', edit_target_path: '编辑目标路径', ignore_exception: '永久忽略', delete_file: '删除文件' }; export const PROVIDER_MODES = [ { id: 'all', label: '多源并行', providers: [] }, { id: 'authoritative', label: '权威优先', providers: ['acoustid', 'musicbrainz'] }, { id: 'netease', label: '网易云', providers: ['netease'] }, { id: 'qq', label: 'QQ 音乐', providers: ['qq'] }, { id: 'spotify', label: 'Spotify', providers: ['spotify'] } ]; export const BULK_ACTIONS = { match_failed: ['retry_match', 'ignore_exception'], low_score: ['retry_match', 'ignore_exception'], organize_failed: ['ignore_exception', 'retry_organize'], missing_tags: ['retry_match', 'ignore_exception'], duplicates: ['ignore_exception'], convert_failed: ['ignore_exception'] }; export const ITEMS_PER_PAGE = 8; export const METADATA_QUEUE_TYPES = ['missing_tags', 'match_failed', 'low_score']; export const METADATA_QUEUE_PAGE_SIZE = 100; export const METADATA_FIELDS = ['title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics']; export const REQUIRED_FIELDS = ['title', 'artist', 'album_artist']; export const WIZARD_STEPS = [ { id: 'select', label: '选择歌曲' }, { id: 'listen', label: '试听确认' }, { id: 'match', label: '推荐匹配' }, { id: 'edit', label: '手动编辑' }, { id: 'confirm', label: '入库确认' } ]; // --- Utility functions --- export function compareTimestampDesc(a, b) { return new Date(b || 0).getTime() - new Date(a || 0).getTime(); } export function formatTimestamp(value) { if (!value) return '--'; try { return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } catch { return String(value); } } export function formatSeconds(value) { if (!Number.isFinite(value) || value <= 0) return '--:--'; const total = Math.floor(value); const minutes = Math.floor(total / 60); const seconds = total % 60; return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } export function formatConfidence(value) { if (value == null) return '--'; return `${Number(value).toFixed(1)} 分`; } export function formatMetadataValue(value) { if (value === null || value === undefined || value === '') return '--'; return String(value); } export function providerLabel(provider) { const labels = { acoustid: 'AcoustID', musicbrainz: 'MusicBrainz', netease: '网易云', qq: 'QQ 音乐', spotify: 'Spotify' }; const key = String(provider || '').toLowerCase(); return labels[key] || provider || '推荐候选'; } export function isTerminalRepairStatus(status) { return status === 'completed' || status === 'failed'; } export function normalizeActionParams(action, params) { if (action === 'retry_match') { return { provider_mode: params.provider_mode || 'all', providers: params.providers || [] }; } if (action === 'save_and_organize' || action === 'edit_metadata') { return { metadata_patch: { ...(params.metadata_patch || {}) } }; } return params; } export function buildDefaultParams(action, detailRecord) { if (action === 'retry_match') { return { provider_mode: 'all', providers: [] }; } if (action === 'select_match_candidate') { return { candidate_index: 0 }; } if (action === 'edit_metadata' || action === 'save_and_organize') { const metadata = detailRecord?.effective_metadata || detailRecord?.matched_metadata_json || detailRecord?.original_tags_json || {}; return { metadata_patch: { title: metadata.title || '', artist: metadata.artist || '', album: metadata.album || '', album_artist: metadata.album_artist || '', track_number: metadata.track_number ?? null, disc_number: metadata.disc_number ?? null, year: metadata.year ?? null, lyrics: metadata.lyrics || '' } }; } if (action === 'retry_organize' || action === 'edit_target_path') { return { target_relative_path: detailRecord?.library_relative_path || '' }; } return {}; } export function isMetadataWorkflowException(exceptionType) { return ['missing_tags', 'match_failed', 'low_score'].includes(exceptionType); } export function inferDefaultAction(detailRecord) { if (!detailRecord) return ''; const actions = detailRecord.available_actions || []; if (actions.includes('save_and_organize')) return 'save_and_organize'; if (actions.includes('retry_match')) return 'retry_match'; if (actions.includes('retry_organize')) return 'retry_organize'; return actions[0] || ''; } export function getMissingRequiredFields(metadata) { return REQUIRED_FIELDS .filter((field) => !String(metadata?.[field] || '').trim()) .map((field) => { const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' }; return labels[field] || field; }); } // --- Styling helpers --- export function chipClass(active, compact = false) { return `rounded-full border px-3 py-1.5 text-xs transition ${ compact ? '' : '' } ${ active ? 'border-indigo-400/50 bg-indigo-500/15 text-indigo-100' : 'border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-500' }`; } export function inputClass() { return 'w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none transition placeholder:text-slate-500 focus:border-indigo-400/60'; } export function actionButtonClass(enabled = true) { return `inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-xs font-medium transition ${ enabled ? 'border border-indigo-400/40 bg-indigo-500/15 text-indigo-100 hover:bg-indigo-500/20' : 'cursor-not-allowed border border-slate-800 bg-slate-900 text-slate-600' }`; } export function riskClass(riskLevel) { return { low: 'bg-emerald-500/10 text-emerald-200', medium: 'bg-amber-500/10 text-amber-200', high: 'bg-rose-500/10 text-rose-200' }[riskLevel || 'low']; } export function getFilterCount(summary, filterId) { if (!summary) return '-'; if (filterId === 'all') return summary.total ?? '-'; return summary.counts_by_type?.[filterId] ?? '-'; } export function getMetadataQueueCounts(queue) { const counts = { missing_tags: 0, match_failed: 0, low_score: 0 }; queue.forEach((item) => { if (counts[item.exception_type] !== undefined) { counts[item.exception_type] += 1; } }); return counts; } export function shouldRefreshExceptionListFully(repairTask) { const plan = repairTask?.repair_plan_json || {}; return (plan.total_exceptions ?? 0) > 1; } ``` - [ ] **Step 2: 更新 ExceptionPage.jsx 的 import 引用常量** 将 `ExceptionPage.jsx` 中第 35-127 行的常量定义和工具函数,替换为从 `../utils/exceptions` 导入。不删除原文件中的定义(如果还有旧代码引用),先添加导入: ```javascript import { EXCEPTION_FILTERS, RESOLUTION_FILTERS, ACTION_LABELS, PROVIDER_MODES, BULK_ACTIONS, ITEMS_PER_PAGE, METADATA_QUEUE_TYPES, METADATA_QUEUE_PAGE_SIZE, WIZARD_STEPS, METADATA_FIELDS, compareTimestampDesc, formatTimestamp, formatSeconds, formatConfidence, isTerminalRepairStatus, normalizeActionParams, buildDefaultParams, isMetadataWorkflowException, inferDefaultAction, getFilterCount, getMetadataQueueCounts, shouldRefreshExceptionListFully, chipClass, actionButtonClass, inputClass, riskClass } from '../utils/exceptions'; ``` 原文件第 35-127 行的常量定义和工具函数保持不变(Phase 3 容器重构时才移除)。 - [ ] **Step 3: 更新 MissingTagsInlinePanel.jsx 的 import 引用标注** `MissingTagsInlinePanel.jsx` 中已有本地定义的这些常量和工具函数,暂不修改(该组件后续可能被新的 wizard 步骤组件替代)。仅验证其可正常渲染。 - [ ] **Step 4: 验证构建** ```bash cd frontend && npm run build ``` Expected: PASS (no errors, no warnings for unused imports from utils is OK) - [ ] **Step 5: Commit** ```bash git add frontend/src/utils/exceptions.js frontend/src/pages/ExceptionPage.jsx git commit -m "feat: extract shared constants and utilities to utils/exceptions.js Co-Authored-By: Claude Opus 4.6 " ``` --- ## Phase 2: 自定义 Hooks ### Task 2.1: 创建 useExceptionSummary hook **Files:** - Create: `frontend/src/hooks/useExceptionSummary.js` - [ ] **Step 1: 编写 hook** ```javascript // frontend/src/hooks/useExceptionSummary.js import { useState, useEffect } from 'react'; import { fetchExceptionSummary } from '../api/exceptions'; export default function useExceptionSummary() { const [summary, setSummary] = useState(null); const [error, setError] = useState(''); const refresh = () => { fetchExceptionSummary() .then(setSummary) .catch((err) => setError(err.message || '异常概览加载失败')); }; useEffect(() => { const controller = new AbortController(); fetchExceptionSummary({ signal: controller.signal }) .then(setSummary) .catch((err) => { if (err.name !== 'AbortError') { setError(err.message || '异常概览加载失败'); } }); return () => controller.abort(); }, []); return { summary, error, refresh }; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/hooks/useExceptionSummary.js git commit -m "feat: add useExceptionSummary hook Co-Authored-By: Claude Opus 4.6 " ``` ### Task 2.2: 创建 useExceptionList hook **Files:** - Create: `frontend/src/hooks/useExceptionList.js` - [ ] **Step 1: 编写 hook** ```javascript // frontend/src/hooks/useExceptionList.js import { useState, useEffect, useCallback } from 'react'; import { fetchExceptionItems } from '../api/exceptions'; import { METADATA_QUEUE_TYPES, METADATA_QUEUE_PAGE_SIZE, ITEMS_PER_PAGE, compareTimestampDesc } from '../utils/exceptions'; export default function useExceptionList({ viewMode, activeFilter, resolutionFilter, currentPage }) { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [metadataQueue, setMetadataQueue] = useState([]); const [metadataTotal, setMetadataTotal] = useState(0); const [isListLoading, setIsListLoading] = useState(true); const [isMetadataQueueLoading, setIsMetadataQueueLoading] = useState(true); const [listError, setListError] = useState(''); const [metadataQueueError, setMetadataQueueError] = useState(''); const refreshList = useCallback(() => { if (viewMode !== 'advanced') return; setIsListLoading(true); setListError(''); fetchExceptionItems({ type: activeFilter, resolutionStatus: resolutionFilter, page: currentPage, pageSize: ITEMS_PER_PAGE }) .then((payload) => { setItems(payload.items); setTotal(payload.total); }) .catch((err) => { setItems([]); setTotal(0); setListError(err.message || '异常列表加载失败'); }) .finally(() => setIsListLoading(false)); }, [viewMode, activeFilter, resolutionFilter, currentPage]); const refreshMetadataQueue = useCallback(() => { setIsMetadataQueueLoading(true); setMetadataQueueError(''); Promise.all( METADATA_QUEUE_TYPES.map((type) => fetchExceptionItems({ type, resolutionStatus: 'open', page: 1, pageSize: METADATA_QUEUE_PAGE_SIZE }) ) ) .then((payloads) => { const queue = payloads .flatMap((p) => p.items) .sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at)); setMetadataQueue(queue); setMetadataTotal(payloads.reduce((sum, p) => sum + p.total, 0)); }) .catch((err) => { setMetadataQueue([]); setMetadataTotal(0); setMetadataQueueError(err.message || '元数据异常队列加载失败'); }) .finally(() => setIsMetadataQueueLoading(false)); }, []); useEffect(() => { const controller = new AbortController(); if (viewMode !== 'advanced') { setIsListLoading(false); return undefined; } setIsListLoading(true); setListError(''); fetchExceptionItems( { type: activeFilter, resolutionStatus: resolutionFilter, page: currentPage, pageSize: ITEMS_PER_PAGE }, { signal: controller.signal } ) .then((payload) => { setItems(payload.items); setTotal(payload.total); }) .catch((err) => { if (err.name !== 'AbortError') { setItems([]); setTotal(0); setListError(err.message || '加载失败'); } }) .finally(() => { if (!controller.signal.aborted) setIsListLoading(false); }); return () => controller.abort(); }, [activeFilter, resolutionFilter, currentPage, viewMode]); useEffect(() => { const controller = new AbortController(); setIsMetadataQueueLoading(true); setMetadataQueueError(''); Promise.all( METADATA_QUEUE_TYPES.map((type) => fetchExceptionItems( { type, resolutionStatus: 'open', page: 1, pageSize: METADATA_QUEUE_PAGE_SIZE }, { signal: controller.signal } ) ) ) .then((payloads) => { const queue = payloads.flatMap((p) => p.items).sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at)); setMetadataQueue(queue); setMetadataTotal(payloads.reduce((sum, p) => sum + p.total, 0)); }) .catch((err) => { if (err.name !== 'AbortError') { setMetadataQueue([]); setMetadataTotal(0); setMetadataQueueError(err.message || '加载失败'); } }) .finally(() => { if (!controller.signal.aborted) setIsMetadataQueueLoading(false); }); return () => controller.abort(); }, [viewMode]); // Refresh when viewMode switches to wizard useEffect(() => { if (viewMode === 'wizard') refreshMetadataQueue(); }, [viewMode, refreshMetadataQueue]); return { items, total, metadataQueue, metadataTotal, isListLoading, isMetadataQueueLoading, listError, metadataQueueError, setItems, setMetadataQueue, refreshList, refreshMetadataQueue }; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/hooks/useExceptionList.js git commit -m "feat: add useExceptionList hook Co-Authored-By: Claude Opus 4.6 " ``` ### Task 2.3: 创建 useExceptionDetail hook **Files:** - Create: `frontend/src/hooks/useExceptionDetail.js` - [ ] **Step 1: 编写 hook** ```javascript // frontend/src/hooks/useExceptionDetail.js import { useState, useEffect, useCallback } from 'react'; import { fetchExceptionItem } from '../api/exceptions'; export default function useExceptionDetail(exceptionId) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const refresh = useCallback(() => { if (!exceptionId) { setDetail(null); return; } setLoading(true); setError(''); fetchExceptionItem(exceptionId) .then(setDetail) .catch((err) => { setDetail(null); setError(err.message || '详情加载失败'); }) .finally(() => setLoading(false)); }, [exceptionId]); useEffect(() => { if (!exceptionId) { setDetail(null); return; } const controller = new AbortController(); setLoading(true); setError(''); fetchExceptionItem(exceptionId, { signal: controller.signal }) .then(setDetail) .catch((err) => { if (err.name !== 'AbortError') { setDetail(null); setError(err.message || '详情加载失败'); } }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); }, [exceptionId]); return { detail, loading, error, refresh }; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/hooks/useExceptionDetail.js git commit -m "feat: add useExceptionDetail hook Co-Authored-By: Claude Opus 4.6 " ``` ### Task 2.4: 创建 useRepairTask hook **Files:** - Create: `frontend/src/hooks/useRepairTask.js` - [ ] **Step 1: 编写 hook** ```javascript // frontend/src/hooks/useRepairTask.js import { useState, useEffect, useRef, useCallback } from 'react'; import { createRepairTaskStream, fetchCurrentRepairTask, fetchRepairTask, fetchRepairTaskLogs } from '../api/repairs'; import { isTerminalRepairStatus } from '../utils/exceptions'; export default function useRepairTask() { const [repairTask, setRepairTask] = useState(null); const [repairLogs, setRepairLogs] = useState([]); const [executionStateByExceptionId, setExecutionStateByExceptionId] = useState({}); const completedRefreshRef = useRef(new Set()); // Load current repair task on mount useEffect(() => { fetchCurrentRepairTask() .then((payload) => { if (!payload.task) return; setRepairTask(payload.task); return fetchRepairTaskLogs(payload.task.task_id, 1, 20).then((lp) => setRepairLogs(lp.logs)); }) .catch(() => {}); }, []); // WebSocket for repair task updates useEffect(() => { if (!repairTask?.task_id) return; const socket = createRepairTaskStream(repairTask.task_id); socket.onmessage = async (event) => { const payload = JSON.parse(event.data); if (payload.type === 'task.snapshot') { setRepairTask(payload.data.task); setRepairLogs(payload.data.recent_logs || []); return; } try { const tp = await fetchRepairTask(repairTask.task_id); const lp = await fetchRepairTaskLogs(repairTask.task_id, 1, 20); setRepairTask(tp.task); setRepairLogs(lp.logs); } catch (err) { console.error('Repair task refresh error', err); } }; return () => socket.close(); }, [repairTask?.task_id]); // Track execution state per exception useEffect(() => { if (!repairTask?.task_id) return; setExecutionStateByExceptionId((prev) => { let changed = false; const next = { ...prev }; Object.entries(prev).forEach(([eid, state]) => { if (!state || state.repairTaskId !== repairTask.task_id) return; const nextStatus = repairTask.status === 'completed' ? 'completed' : repairTask.status === 'failed' ? 'failed' : repairTask.status === 'running' ? 'running' : 'accepted'; const nextError = repairTask.status === 'failed' ? repairTask.error_message || '执行失败' : ''; if (state.status !== nextStatus || state.error !== nextError) { next[eid] = { ...state, status: nextStatus, error: nextError }; changed = true; } }); return changed ? next : prev; }); }, [repairTask]); const registerExecution = useCallback((exceptionId, action, repairTaskId, previewPayload) => { setExecutionStateByExceptionId((prev) => ({ ...prev, [exceptionId]: { exceptionId, action, status: 'accepted', repairTaskId, submittedAt: new Date().toISOString(), error: '', previewPayload } })); }, []); const setExecuting = useCallback((exceptionId, action, previewPayload) => { setExecutionStateByExceptionId((prev) => ({ ...prev, [exceptionId]: { exceptionId, action, status: 'submitting', repairTaskId: null, submittedAt: new Date().toISOString(), error: '', previewPayload: previewPayload || prev[exceptionId]?.previewPayload } })); }, []); const setExecutionFailed = useCallback((exceptionId, action, errorMessage, previewPayload) => { setExecutionStateByExceptionId((prev) => ({ ...prev, [exceptionId]: { exceptionId, action, status: 'failed', error: errorMessage, previewPayload: previewPayload || prev[exceptionId]?.previewPayload } })); }, []); const isTerminal = isTerminalRepairStatus(repairTask?.status); const taskCompleted = isTerminal && completedRefreshRef; return { repairTask, repairLogs, executionStateByExceptionId, registerExecution, setExecuting, setExecutionFailed, completedRefreshRef, taskCompleted, setRepairTask, setRepairLogs }; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/hooks/useRepairTask.js git commit -m "feat: add useRepairTask hook for WebSocket repair task tracking Co-Authored-By: Claude Opus 4.6 " ``` ### Task 2.5: 创建 useWizardState hook **Files:** - Create: `frontend/src/hooks/useWizardState.js` - [ ] **Step 1: 编写 hook** ```javascript // frontend/src/hooks/useWizardState.js import { useState, useCallback } from 'react'; import { buildDefaultParams, inferDefaultAction, normalizeActionParams } from '../utils/exceptions'; export default function useWizardState(initialStep = 'select') { const [wizardStep, setWizardStep] = useState(initialStep); const [selectedAction, setSelectedAction] = useState(''); const [actionParams, setActionParams] = useState({}); const [previewState, setPreviewState] = useState({ loading: false, payload: null, error: '', action: '' }); const [executeError, setExecuteError] = useState(''); const initForDetail = useCallback((detailRecord, viewMode) => { if (!detailRecord) { setSelectedAction(''); setActionParams({}); setPreviewState({ loading: false, payload: null, error: '', action: '' }); setExecuteError(''); return; } const nextAction = viewMode === 'wizard' && ['missing_tags', 'match_failed', 'low_score'].includes(detailRecord.exception_type) ? 'save_and_organize' : inferDefaultAction(detailRecord); setSelectedAction(nextAction); setActionParams(buildDefaultParams(nextAction, detailRecord)); setPreviewState({ loading: false, payload: null, error: '', action: '' }); setExecuteError(''); }, []); const focusAction = useCallback((action) => { setSelectedAction(action); setPreviewState({ loading: false, payload: null, error: '', action: '' }); setExecuteError(''); }, []); const resetPreview = useCallback(() => { setPreviewState({ loading: false, payload: null, error: '', action: '' }); }, []); return { wizardStep, setWizardStep, selectedAction, setSelectedAction, actionParams, setActionParams, previewState, setPreviewState, executeError, setExecuteError, initForDetail, focusAction, resetPreview }; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/hooks/useWizardState.js git commit -m "feat: add useWizardState hook Co-Authored-By: Claude Opus 4.6 " ``` --- ## Phase 3: 拆分视觉组件 ### Task 3.1: 创建 ExceptionStatsBar 组件 **Files:** - Create: `frontend/src/components/exceptions/ExceptionStatsBar.jsx` - [ ] **Step 1: 编写组件** ```javascript // 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', cyan: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200' }; return (
{value}
{label}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/exceptions/ExceptionStatsBar.jsx git commit -m "feat: add ExceptionStatsBar component Co-Authored-By: Claude Opus 4.6 " ``` ### Task 3.2: 创建 ExceptionTypeNav 组件 **Files:** - Create: `frontend/src/components/exceptions/ExceptionTypeNav.jsx` - [ ] **Step 1: 编写组件** ```javascript // 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) => ( ))}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/exceptions/ExceptionTypeNav.jsx git commit -m "feat: add ExceptionTypeNav component Co-Authored-By: Claude Opus 4.6 " ``` ### Task 3.3: 创建 RepairTaskPanel 组件 **Files:** - Create: `frontend/src/components/exceptions/RepairTaskPanel.jsx` - [ ] **Step 1: 编写组件** ```javascript // 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} )}
)} {repairLogs.length > 0 && (
{repairLogs.map((log, i) => (
{log.message || log.stage || '--'}
))}
)}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/exceptions/RepairTaskPanel.jsx git commit -m "feat: add RepairTaskPanel component Co-Authored-By: Claude Opus 4.6 " ``` ### Task 3.4: 创建 ActionPreviewModal 组件 **Files:** - Create: `frontend/src/components/exceptions/ActionPreviewModal.jsx` - [ ] **Step 1: 编写组件** ```javascript // frontend/src/components/exceptions/ActionPreviewModal.jsx import { ShieldAlert, X } from 'lucide-react'; import { riskClass } from '../../utils/exceptions'; export default function ActionPreviewModal({ previewState, onClose, onConfirm, onCancel }) { if (!previewState || !previewState.payload) return null; const { payload, loading, error, action } = previewState; if (loading) { return (

正在生成预览...

); } if (error) { return (

{error}

); } return (

操作预览

风险 {payload.risk_level}
{payload.items?.map((item) => (
{item.filename}
{item.planned_operations?.map((op, i) => (
{op.description}
))}
))}
{payload.warnings?.length > 0 && (
{payload.warnings.map((w, i) =>
⚠ {w}
)}
)}
); } // Need LoaderCircle from lucide-react import { LoaderCircle } from 'lucide-react'; ``` Note: Move the `LoaderCircle` import to the top of the file alongside the existing imports. - [ ] **Step 2: Fix the import order — put LoaderCircle at top** ```javascript import { LoaderCircle, ShieldAlert, X } from 'lucide-react'; ``` - [ ] **Step 3: Commit** ```bash git add frontend/src/components/exceptions/ActionPreviewModal.jsx git commit -m "feat: add ActionPreviewModal component Co-Authored-By: Claude Opus 4.6 " ``` --- ## Phase 4: 拆分向导步骤组件 ### Task 4.1: 创建 StepSelect 组件 **Files:** - Create: `frontend/src/components/exceptions/steps/StepSelect.jsx` - [ ] **Step 1: 编写组件** ```javascript // 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 (
); } if (error) { return (
{error}
); } if (!metadataQueue.length) { return (

没有待处理的异常

); } return (
{metadataQueue.map((item) => { const selected = selectedExceptionId === item.exception_id; const ap = item.audio_props_json || {}; return ( ); })}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/exceptions/steps/StepSelect.jsx git commit -m "feat: add StepSelect wizard component Co-Authored-By: Claude Opus 4.6 " ``` ### Task 4.2: 创建 StepListen 组件 **Files:** - Create: `frontend/src/components/exceptions/steps/StepListen.jsx` - [ ] **Step 1: 编写组件** ```javascript // frontend/src/components/exceptions/steps/StepListen.jsx import { useState, useRef, useEffect, useCallback } from 'react'; import { Headphones, Play, Pause, LoaderCircle } from 'lucide-react'; import { buildExceptionAudioUrl } from '../../../api/exceptions'; import { formatSeconds } from '../../../utils/exceptions'; 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 (

请先选择一首歌曲

); } if (isLoading) { return (
); } 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 (
{/* File info */}

{detailRecord.display_title || '-'}

{detailRecord.filename}

{/* Audio player */}

试听预览

在线试听

); } function InfoField({ label, value, mono = false }) { return (
{label}
{value || '--'}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/exceptions/steps/StepListen.jsx git commit -m "feat: add StepListen wizard component Co-Authored-By: Claude Opus 4.6 " ``` ### Task 4.3: 创建 StepMatch 组件 **Files:** - Create: `frontend/src/components/exceptions/steps/StepMatch.jsx` - [ ] **Step 1: 编写组件** ```javascript // frontend/src/components/exceptions/steps/StepMatch.jsx import { Search, Sparkles, LoaderCircle, ShieldAlert } from 'lucide-react'; import { PROVIDER_MODES, chipClass, actionButtonClass, formatConfidence, providerLabel } from '../../../utils/exceptions'; export default function StepMatch({ detailRecord, isLoading, providerMode, setProviderMode, providers, setProviders, previewState, executionState, onPreview, onExecute }) { if (!detailRecord && !isLoading) { return (

请先选择一首歌曲

); } if (isLoading) { return (
); } const candidates = detailRecord.match_candidates_json || []; const isPreviewing = previewState.action === 'retry_match' && previewState.loading; const isExecuting = executionState?.action === 'retry_match'; return (
{/* File info */}

{detailRecord.display_title || '-'}

{detailRecord.filename}

{detailRecord.display_reason || '-'}
{/* Existing candidates */} {candidates.length > 0 && (

现有匹配候选

{candidates.map((candidate, i) => (
{candidate.title || 'Unknown'}
{[candidate.artist, candidate.album].filter(Boolean).join(' · ')} {candidate.year ? ` · ${candidate.year}` : ''}
{candidate.score != null && ( = 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)}% )}
{candidate.source && (
{providerLabel(candidate.source)}
)}
))}
)} {/* Retry match controls */}

重新匹配

选择匹配来源后执行重新匹配,结果会自动填充到编辑区。

{PROVIDER_MODES.map((mode) => { const active = providerMode === mode.id; return ( ); })}
{isPreviewing && (
正在生成匹配预览...
)} {/* Preview results */} {previewState.action === 'retry_match' && previewState.payload && !previewState.loading && (
匹配预览结果
{previewState.payload.items?.map((item) => (
{item.filename}
{item.planned_operations?.map((op, i) => (
{op.description}
))}
))}
)} {previewState.error && previewState.action === 'retry_match' && (

{previewState.error}

)} {isExecuting && (
)}
); } function InfoField({ label, value }) { return (
{label}
{value || '--'}
); } 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 {label}; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/exceptions/steps/StepMatch.jsx git commit -m "feat: add StepMatch wizard component Co-Authored-By: Claude Opus 4.6 " ``` ### Task 4.4: 创建 StepEdit 组件 **Files:** - Create: `frontend/src/components/exceptions/steps/StepEdit.jsx` - [ ] **Step 1: 编写组件** ```javascript // frontend/src/components/exceptions/steps/StepEdit.jsx import { LoaderCircle, Sparkles } from 'lucide-react'; import { METADATA_FIELDS, REQUIRED_FIELDS, inputClass, actionButtonClass, formatMetadataValue } from '../../../utils/exceptions'; export default function StepEdit({ detailRecord, isLoading, metadataPatch, onUpdateMetadata, canIngest, missingFields, previewState, onPreview }) { if (!detailRecord && !isLoading) { return (

请先选择一首歌曲

); } if (isLoading) { return (
); } const draft = metadataPatch && Object.keys(metadataPatch).length > 0 ? metadataPatch : detailRecord.effective_metadata || {}; return (
{/* File info and edit form */}

{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="年份" />