Files
MusicWorkshop/docs/superpowers/plans/2026-05-07-exception-center-redesign.md
liumangmang be3c086975 chore: add project configs, backend repair services, docs, and code quality tooling
- Add pre-commit hooks (ruff, black, prettier) and ESLint/Prettier configs
- Add backend repair services (execution, orchestration, preview) with tests
- Add project documentation (CLAUDE.md, README.md, design specs and plans)
- Add MissingTagsInlinePanel component for exception handling
- Add pyproject.toml with ruff/black configuration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:49:37 +08:00

2780 lines
103 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 异常中心页面优化 — 实施计划
> **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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
### 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 <noreply@anthropic.com>"
```
---
## 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 (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MetricCard label="待补全" value={metadataTotal || 0} tone="indigo" />
<MetricCard label="缺标签" value={metadataQueueCounts?.missing_tags ?? 0} tone="amber" />
<MetricCard label="匹配失败" value={metadataQueueCounts?.match_failed ?? 0} tone="rose" />
<MetricCard label="低分匹配" value={metadataQueueCounts?.low_score ?? 0} tone="emerald" />
</div>
);
}
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MetricCard label="全部开放" value={summary?.total ?? '-'} tone="indigo" />
<MetricCard label="重复" value={summary?.counts_by_type?.duplicates ?? '-'} tone="amber" />
<MetricCard label="匹配失败" value={summary?.counts_by_type?.match_failed ?? '-'} tone="rose" />
<MetricCard label="入库失败" value={summary?.counts_by_type?.organize_failed ?? '-'} tone="emerald" />
</div>
);
}
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 (
<div className={`rounded-2xl border ${colors[tone] || colors.indigo} p-4 text-center transition hover:-translate-y-0.5`}>
<div className="text-2xl font-bold">{value}</div>
<div className="mt-1 text-xs opacity-70">{label}</div>
</div>
);
}
```
- [ ] **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 <noreply@anthropic.com>"
```
### 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 (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{EXCEPTION_FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => onFilterChange(filter.id)}
className={chipClass(activeFilter === filter.id)}
>
{filter.name}
<span className="ml-2 font-mono text-[11px]">{getFilterCount(summary, filter.id)}</span>
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.2em] text-slate-500">处理状态</span>
{RESOLUTION_FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => onResolutionChange(filter.id)}
className={chipClass(resolutionFilter === filter.id)}
>
{filter.name}
</button>
))}
</div>
</div>
);
}
```
- [ ] **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 <noreply@anthropic.com>"
```
### 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 (
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
<div className="flex items-center gap-2 text-xs text-slate-400">
<LoaderCircle className={`h-3 w-3 ${repairTask && !isTerminalRepairStatus(repairTask.status) ? 'animate-spin' : ''}`} />
<span> 修复任务</span>
{repairTask && (
<span className={`ml-auto rounded-full px-2 py-0.5 text-[11px] ${statusColor[repairTask.status] || ''}`}>
{statusLabel[repairTask.status] || repairTask.status}
</span>
)}
</div>
{repairTask && (
<div className="mt-2 text-xs text-slate-500">
任务号: {repairTask.task_id}
{repairTask.error_message && (
<span className="ml-2 text-rose-400">{repairTask.error_message}</span>
)}
</div>
)}
{repairLogs.length > 0 && (
<div className="mt-2 max-h-[120px] 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>
);
}
```
- [ ] **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 <noreply@anthropic.com>"
```
### 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="rounded-2xl border border-slate-800 bg-slate-950 p-6 text-center text-slate-400">
<LoaderCircle className="mx-auto h-8 w-8 animate-spin" />
<p className="mt-3 text-sm">正在生成预览...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-6 max-w-md">
<p className="text-sm text-rose-200">{error}</p>
<button onClick={onClose} className="mt-4 text-xs text-slate-400">关闭</button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="max-h-[80vh] w-full max-w-2xl overflow-auto rounded-2xl border border-indigo-900/40 bg-slate-950 p-6 shadow-2xl">
<div className="flex items-center justify-between gap-3">
<h3 className="flex items-center gap-2 text-lg font-semibold text-white">
<ShieldAlert className="h-5 w-5 text-amber-300" />
操作预览
</h3>
<div className="flex items-center gap-3">
<span className={`rounded-full px-2.5 py-1 text-[11px] ${riskClass(payload.risk_level)}`}>
风险 {payload.risk_level}
</span>
<button onClick={onClose} className="text-slate-500 hover:text-slate-300">
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="mt-4 space-y-3">
{payload.items?.map((item) => (
<div key={item.exception_id} className="rounded-xl border border-indigo-900/30 bg-slate-900/60 p-4">
<div className="text-sm font-medium text-slate-100">{item.filename}</div>
<div className="mt-2 space-y-1">
{item.planned_operations?.map((op, i) => (
<div key={`${op.type}-${i}`} className="text-xs text-slate-400">{op.description}</div>
))}
</div>
</div>
))}
</div>
{payload.warnings?.length > 0 && (
<div className="mt-4 space-y-1 text-xs text-amber-200">
{payload.warnings.map((w, i) => <div key={i}> {w}</div>)}
</div>
)}
<div className="mt-6 flex justify-end gap-3">
<button onClick={onCancel || onClose}
className="rounded-xl border border-slate-700 bg-slate-900 px-4 py-2 text-sm text-slate-300 hover:bg-slate-800">
取消
</button>
<button onClick={onConfirm}
className="rounded-xl bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400">
确认执行
</button>
</div>
</div>
</div>
);
}
// 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 <noreply@anthropic.com>"
```
---
## 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 (
<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>
);
}
if (error) {
return (
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">
{error}
</div>
);
}
if (!metadataQueue.length) {
return (
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-slate-500">
<Music2 className="mb-4 h-12 w-12 opacity-30" />
<p className="text-sm">没有待处理的异常</p>
</div>
);
}
return (
<div className="space-y-2">
{metadataQueue.map((item) => {
const selected = selectedExceptionId === item.exception_id;
const ap = item.audio_props_json || {};
return (
<button
key={item.exception_id}
onClick={() => onSelectItem(item.exception_id)}
className={`w-full rounded-2xl border p-4 text-left transition ${
selected
? 'border-indigo-400/60 bg-indigo-500/10 shadow-[0_0_0_1px_rgba(99,102,241,0.08)]'
: 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:bg-slate-900'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-slate-100">{item.display_title}</div>
<div className="mt-1 truncate font-mono text-[11px] text-indigo-300/80">{item.filename}</div>
</div>
<span className="shrink-0 rounded-full border border-slate-700 bg-slate-950 px-2.5 py-1 text-[11px] text-slate-300">
{item.type_label}
</span>
</div>
<div className="mt-3 line-clamp-2 text-xs leading-5 text-slate-400">{item.display_reason}</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-slate-500">
<span>{ap.duration_seconds ? `${Math.floor(ap.duration_seconds / 60)}:${String(Math.floor(ap.duration_seconds % 60)).padStart(2, '0')}` : '--:--'}</span>
<span className="text-right">{ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'}</span>
</div>
</button>
);
})}
</div>
);
}
```
- [ ] **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 <noreply@anthropic.com>"
```
### 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 (
<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 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 (
<div className="space-y-4">
{/* File info */}
<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>
<p className="mt-1 font-mono text-xs text-indigo-300/80">{detailRecord.filename}</p>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-300">
<InfoField label="格式" value={ap.format || '--'} />
<InfoField label="编码" value={ap.codec || '--'} />
<InfoField label="采样率" value={ap.sample_rate ? `${ap.sample_rate} Hz` : '--'} />
<InfoField label="比特率" value={ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'} />
<InfoField label="位深" value={ap.bit_depth ? `${ap.bit_depth} bit` : '--'} />
<InfoField label="时长" value={formatSeconds(ap.duration_seconds)} />
</div>
</div>
{/* Audio player */}
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">试听预览</p>
<h4 className="mt-2 flex items-center gap-2 text-sm font-medium text-white">
<Headphones className="h-4 w-4 text-emerald-300" />在线试听
</h4>
</div>
<button
onClick={togglePlay}
className="flex h-11 w-11 items-center justify-center rounded-full border border-emerald-500/40 bg-emerald-500/10 text-emerald-200 transition hover:bg-emerald-500/20"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
</button>
</div>
<audio ref={audioRef} src={audioUrl} preload="metadata"
onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
onError={() => setAudioError('音频文件不可用或已丢失')}
className="hidden"
/>
<div className="mt-4">
<input type="range" min="0" max={duration || 0} step="0.1"
value={Math.min(currentTime, duration || 0)}
onChange={(e) => { const v = Number(e.target.value); setCurrentTime(v); if (audioRef.current) audioRef.current.currentTime = v; }}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-800 accent-emerald-400"
/>
<div className="mt-2 flex items-center justify-between font-mono text-[11px] text-slate-500">
<span>{formatSeconds(currentTime)}</span><span>{formatSeconds(duration)}</span>
</div>
</div>
{audioError ? <p className="mt-3 text-xs text-rose-300">{audioError}</p> : null}
</div>
</div>
);
}
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>
);
}
```
- [ ] **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 <noreply@anthropic.com>"
```
### 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 (
<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 candidates = detailRecord.match_candidates_json || [];
const isPreviewing = previewState.action === 'retry_match' && previewState.loading;
const isExecuting = executionState?.action === 'retry_match';
return (
<div className="space-y-4">
{/* File info */}
<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>
<p className="mt-1 font-mono text-xs text-indigo-300/80">{detailRecord.filename}</p>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-300">
<InfoField label="匹配来源" value={detailRecord.match_source || '--'} />
<InfoField label="匹配分数" value={formatConfidence(detailRecord.match_confidence)} />
</div>
<div className="mt-3 rounded-xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
{detailRecord.display_reason || '-'}
</div>
</div>
{/* Existing candidates */}
{candidates.length > 0 && (
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<h4 className="text-sm font-medium text-white">现有匹配候选</h4>
<div className="mt-3 space-y-2">
{candidates.map((candidate, i) => (
<div key={i} className={`rounded-xl border p-3 ${
i === 0 ? 'border-indigo-400/30 bg-indigo-500/5' : 'border-slate-800 bg-slate-900/60'
}`}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-slate-100">
{candidate.title || 'Unknown'}
</div>
<div className="mt-1 text-xs text-slate-400">
{[candidate.artist, candidate.album].filter(Boolean).join(' · ')}
{candidate.year ? ` · ${candidate.year}` : ''}
</div>
</div>
{candidate.score != null && (
<span className={`rounded-full px-2 py-1 text-[11px] font-medium ${
candidate.score >= 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)}%
</span>
)}
</div>
{candidate.source && (
<div className="mt-2 text-[11px] text-slate-500">{providerLabel(candidate.source)}</div>
)}
</div>
))}
</div>
</div>
)}
{/* Retry match controls */}
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<h4 className="text-sm font-medium text-white">重新匹配</h4>
<p className="mt-2 text-xs leading-5 text-slate-400">选择匹配来源后执行重新匹配结果会自动填充到编辑区</p>
<div className="mt-4 flex flex-wrap gap-2">
{PROVIDER_MODES.map((mode) => {
const active = providerMode === mode.id;
return (
<button key={mode.id} onClick={() => { setProviderMode(mode.id); setProviders(mode.providers); }}
className={chipClass(active)}>
{mode.label}
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button onClick={() => onPreview('retry_match')} className={actionButtonClass(true)}>
<Search className="h-3.5 w-3.5" />预览匹配
</button>
<button onClick={() => onExecute('retry_match')}
className="rounded-xl bg-indigo-500 px-3 py-2 text-sm font-medium text-white">
执行匹配
</button>
</div>
{isPreviewing && (
<div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成匹配预览...
</div>
)}
{/* Preview results */}
{previewState.action === 'retry_match' && previewState.payload && !previewState.loading && (
<div className="mt-4 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>
<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>
<div className="mt-2 space-y-1">
{item.planned_operations?.map((op, i) => (
<div key={`${op.type}-${i}`} className="text-slate-400">{op.description}</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{previewState.error && previewState.action === 'retry_match' && (
<p className="mt-3 text-xs text-rose-300">{previewState.error}</p>
)}
{isExecuting && (
<div className="mt-3">
<StatusBadge status={executionState.status} />
</div>
)}
</div>
</div>
);
}
function InfoField({ label, value }) {
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">{value || '--'}</div>
</div>
);
}
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 <span className={`rounded-full border px-2.5 py-1 text-xs ${map[status]}`}>{label}</span>;
}
```
- [ ] **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 <noreply@anthropic.com>"
```
### 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 (
<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 draft = metadataPatch && Object.keys(metadataPatch).length > 0
? metadataPatch
: detailRecord.effective_metadata || {};
return (
<div className="space-y-4">
{/* File info and edit form */}
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-white">{detailRecord.display_title || '-'}</h3>
<p className="mt-1 font-mono text-xs text-indigo-300/80">{detailRecord.filename}</p>
</div>
<span className={`rounded-full border px-2.5 py-1 text-[11px] ${
canIngest ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
: 'border-rose-500/30 bg-rose-500/10 text-rose-200'
}`}>
{canIngest ? '可入库' : '缺少必填'}
</span>
</div>
{detailRecord.album_artist_reason && (
<div className="mt-4 rounded-xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
{detailRecord.album_artist_reason}
</div>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
<input className={inputClass()} value={draft.title || ''} onChange={(e) => onUpdateMetadata('title', e.target.value)} placeholder="标题 *" />
<input className={inputClass()} value={draft.artist || ''} onChange={(e) => onUpdateMetadata('artist', e.target.value)} placeholder="艺术家 *" />
<input className={inputClass()} value={draft.album_artist || ''} onChange={(e) => onUpdateMetadata('album_artist', e.target.value)} placeholder="专辑艺术家 *" />
<input className={inputClass()} value={draft.album || ''} onChange={(e) => onUpdateMetadata('album', e.target.value)} placeholder="专辑" />
<input className={inputClass()} value={draft.track_number ?? ''} onChange={(e) => onUpdateMetadata('track_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="曲目号" />
<input className={inputClass()} value={draft.disc_number ?? ''} onChange={(e) => onUpdateMetadata('disc_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="碟号" />
<input className={inputClass()} value={draft.year ?? ''} onChange={(e) => onUpdateMetadata('year', e.target.value === '' ? null : Number(e.target.value))} placeholder="年份" />
</div>
<textarea className={`${inputClass()} mt-2 min-h-[96px] resize-y`}
value={draft.lyrics || ''} onChange={(e) => onUpdateMetadata('lyrics', e.target.value)} placeholder="歌词" />
<div className="mt-4 grid grid-cols-3 gap-2 text-xs">
{REQUIRED_FIELDS.map((field) => {
const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' };
const present = String(draft[field] || '').trim();
return (
<div key={field} className={`rounded-xl border px-3 py-2 ${
present ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100'
: 'border-rose-500/30 bg-rose-500/10 text-rose-100'
}`}>{labels[field]}</div>
);
})}
</div>
</div>
{/* Preview ingest */}
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
<h4 className="text-sm font-medium text-white">入库预览</h4>
<p className="mt-2 text-xs leading-5 text-slate-400">点击下方按钮生成后端计算的最终元数据和入库路径</p>
<button onClick={() => onPreview('save_and_organize')}
disabled={!canIngest}
className={`mt-4 inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium transition ${
canIngest ? 'bg-slate-100 text-slate-900' : 'cursor-not-allowed bg-slate-800 text-slate-500'
}`}>
<Sparkles className="h-3.5 w-3.5" />刷新入库预览
</button>
{previewState.action === 'save_and_organize' && previewState.loading && (
<div className="mt-4 flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-400">
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成入库确认...
</div>
)}
{previewState.error && previewState.action === 'save_and_organize' && (
<p className="mt-3 text-xs text-rose-300">{previewState.error}</p>
)}
{previewState.action === 'save_and_organize' && previewState.payload && !previewState.loading && (
<FinalMetadataPreview previewState={previewState} selectedId={detailRecord.exception_id} />
)}
</div>
</div>
);
}
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 (
<div className="mt-4 space-y-3">
<div className="rounded-xl 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">
<Sparkles className="h-4 w-4 text-amber-300" />入库确认
</h5>
</div>
<div className="mt-3 grid gap-3 text-xs text-slate-300">
<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">目标相对路径</div>
<div className="mt-1 break-all font-mono text-[11px] text-slate-100">{finalPreview.target_relative_path}</div>
</div>
<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">完整目标文件路径</div>
<div className="mt-1 break-all font-mono text-[11px] text-slate-100">{finalPreview.target_file_path}</div>
</div>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-950/60">
<table className="min-w-[620px] w-full border-collapse text-left text-xs">
<thead className="bg-slate-900/70 text-[11px] uppercase tracking-[0.14em] text-slate-500">
<tr>
<th className="w-40 px-3 py-3 font-medium">字段</th>
<th className="px-3 py-3 font-medium">最终值</th>
<th className="w-36 px-3 py-3 font-medium">来源</th>
</tr>
</thead>
<tbody>
{METADATA_FIELDS.map((field) => (
<tr key={field} className="border-t border-slate-800/80">
<td className="px-3 py-3 font-mono text-[11px] text-indigo-100">{field}</td>
<td className="px-3 py-3 whitespace-pre-wrap break-all text-slate-100">
{formatMetadataValue(finalPreview.metadata?.[field])}
</td>
<td className="px-3 py-3 text-slate-400">{finalPreview.metadata_sources?.[field] || '--'}</td>
</tr>
))}
</tbody>
</table>
</div>
{finalItem?.planned_operations?.length > 0 && (
<div className="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<h5 className="text-sm font-medium text-white">计划操作</h5>
<div className="mt-3 space-y-2 text-xs text-slate-300">
{finalItem.planned_operations.map((op, i) => (
<div key={`${op.type}-${i}`} className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="text-slate-100">{op.description}</div>
{op.target_path && (
<div className="mt-1 break-all font-mono text-[11px] text-slate-500">{op.target_path}</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/src/components/exceptions/steps/StepEdit.jsx
git commit -m "feat: add StepEdit wizard component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
### Task 4.5: 创建 StepConfirm 组件
**Files:**
- Create: `frontend/src/components/exceptions/steps/StepConfirm.jsx`
- [ ] **Step 1: 编写组件**
```javascript
// frontend/src/components/exceptions/steps/StepConfirm.jsx
import { ShieldAlert, Sparkles, LoaderCircle } from 'lucide-react';
import {
actionButtonClass, riskClass, formatSeconds, formatTimestamp,
getMissingRequiredFields, normalizeActionParams, isTerminalRepairStatus
} from '../../../utils/exceptions';
export default function StepConfirm({
detailRecord, isLoading,
selectedAction, metadataPatch, providerMode, providers,
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 draft = metadataPatch && Object.keys(metadataPatch).length > 0
? metadataPatch
: detailRecord.effective_metadata || {};
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">
{/* Summary card */}
<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>
{/* Path preview */}
{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>
)}
{/* Ignore/Delete preview */}
{(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">
<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 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}
{/* Action buttons */}
<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>
{/* Execution status */}
{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>
)}
{/* Repair logs */}
{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>
);
}
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>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/src/components/exceptions/steps/StepConfirm.jsx
git commit -m "feat: add StepConfirm wizard component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Phase 5: 组装 ExceptionWizard 和 ExceptionListView
### Task 5.1: 创建 ExceptionWizard 组件
**Files:**
- Create: `frontend/src/components/exceptions/ExceptionWizard.jsx`
- [ ] **Step 1: 编写组件**
```javascript
// frontend/src/components/exceptions/ExceptionWizard.jsx
import { Music2, Wrench, Check } from 'lucide-react';
import { WIZARD_STEPS, actionButtonClass, getMetadataQueueCounts, 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 stepIndex = WIZARD_STEPS.findIndex((s) => s.id === wizardStep);
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 (
<StepSelect
metadataQueue={metadataQueue}
selectedExceptionId={selectedExceptionId}
isLoading={isMetadataQueueLoading}
error={metadataQueueError}
onSelectItem={(id) => { onSelectItem(id); onSetWizardStep('listen'); }}
/>
);
case 'listen':
return <StepListen detailRecord={detailRecord} isLoading={isDetailLoading} />;
case 'match':
return (
<StepMatch
detailRecord={detailRecord} isLoading={isDetailLoading}
providerMode={providerMode} setProviderMode={setProviderMode}
providers={providers} setProviders={setProviders}
previewState={previewState} executionState={executionState}
onPreview={onPreview} onExecute={onExecute}
/>
);
case 'edit':
return (
<StepEdit
detailRecord={detailRecord} isLoading={isDetailLoading}
metadataPatch={actionParams.metadata_patch || {}}
onUpdateMetadata={onUpdateMetadata}
canIngest={canIngest} missingFields={missingFields}
previewState={previewState} onPreview={onPreview}
/>
);
case 'confirm':
return (
<StepConfirm
detailRecord={detailRecord} isLoading={isDetailLoading}
selectedAction={selectedAction}
metadataPatch={actionParams.metadata_patch || {}}
providerMode={providerMode} providers={providers}
previewState={previewState} executionState={executionState}
repairTask={repairTask} repairLogs={repairLogs}
onPreview={onPreview} onExecute={onExecute}
canIngest={canIngest}
/>
);
default:
return null;
}
};
return (
<div className="flex min-h-[calc(100vh-120px)] flex-col gap-6 py-6">
{/* Header */}
<section className="rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_left,_rgba(99,102,241,0.08),_transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
<div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-indigo-300/70">单曲补全向导</p>
<h2 className="-mt-1 flex items-center gap-3 text-2xl font-semibold text-white">
<Music2 className="h-6 w-6 text-indigo-300" />
元数据异常补全
</h2>
<p className="mt-2 max-w-3xl text-sm text-slate-400">
只处理元数据缺失匹配失败和低分匹配重复文件转码失败和入库失败保留在高级处理中
</p>
</div>
<ExceptionStatsBar
summary={summary}
metadataTotal={metadataTotal}
metadataQueueCounts={metadataQueueCounts}
viewMode="wizard"
/>
</div>
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<WizardProgressBar currentStep={wizardStep} onSelectStep={onSetWizardStep} />
<button onClick={onSwitchToAdvanced} className={actionButtonClass(true)}>
<Wrench className="h-3.5 w-3.5" />
高级处理
</button>
</div>
</section>
{/* Main content: queue + workspace */}
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[minmax(320px,0.42fr)_minmax(0,1fr)]">
{/* Left: Queue */}
<section className="min-h-[420px] overflow-hidden rounded-[28px] border border-slate-800/90 bg-slate-950/80 shadow-[0_24px_80px_rgba(2,6,23,0.35)]">
<div className="border-b border-slate-800/80 p-5">
<p className="text-xs uppercase tracking-[0.22em] text-slate-500">待处理队列</p>
<h3 className="mt-2 text-lg font-semibold text-white">{metadataQueue.length} 个待处理</h3>
</div>
<div className="max-h-[calc(100vh-330px)] min-h-[320px] overflow-auto p-3">
{wizardStep === 'select' ? renderStep() : (
<StepSelect
metadataQueue={metadataQueue}
selectedExceptionId={selectedExceptionId}
isLoading={isMetadataQueueLoading}
error={metadataQueueError}
onSelectItem={(id) => { onSelectItem(id); onSetWizardStep('listen'); }}
/>
)}
</div>
</section>
{/* Right: Workspace */}
<section className="min-h-0 overflow-auto rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_right,_rgba(34,197,94,0.08),_transparent_28%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
{!detailRecord && !isDetailLoading && wizardStep !== 'select' ? (
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-slate-500">
<Music2 className="mb-4 h-12 w-12 opacity-30" />
<p className="text-sm">从左侧队列选择一个文件开始处理</p>
</div>
) : detailError ? (
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">{detailError}</div>
) : (
<div className="space-y-4 pb-8">
{wizardStep !== 'select' && renderStep()}
</div>
)}
</section>
</div>
{/* Bottom: Repair task panel */}
{repairTask && (
<div className="rounded-2xl border border-slate-800/90 bg-slate-950/80 p-4">
<RepairTaskPanel repairTask={repairTask} repairLogs={repairLogs} executionState={executionState} />
</div>
)}
</div>
);
}
function WizardProgressBar({ currentStep, onSelectStep }) {
return (
<div className="flex items-center gap-2">
{WIZARD_STEPS.map((step, index) => {
const stepIndex = WIZARD_STEPS.findIndex((s) => s.id === currentStep);
const isCompleted = index < stepIndex;
const isCurrent = index === stepIndex;
const isPending = index > stepIndex;
return (
<div key={step.id} className="flex items-center gap-2">
<button
onClick={() => onSelectStep(step.id)}
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium transition ${
isCurrent
? 'bg-indigo-500/20 border border-indigo-400/50 text-indigo-200'
: isCompleted
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-200'
: 'border border-slate-700 text-slate-500 hover:border-slate-600'
}`}
>
{isCompleted ? (
<Check className="h-3 w-3" />
) : (
<span className="flex h-4 w-4 items-center justify-center rounded-full text-[10px]">
{index + 1}
</span>
)}
{step.label}
</button>
{index < WIZARD_STEPS.length - 1 && (
<div className={`h-px w-4 ${isCompleted ? 'bg-emerald-500/30' : 'bg-slate-700'}`} />
)}
</div>
);
})}
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add frontend/src/components/exceptions/ExceptionWizard.jsx
git commit -m "feat: add ExceptionWizard component orchestrating 5-step wizard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
### Task 5.2: 创建 ExceptionListTable 和 ExceptionListView 组件
**Files:**
- Create: `frontend/src/components/exceptions/ExceptionListTable.jsx`
- Create: `frontend/src/components/exceptions/ExceptionListView.jsx`
- [ ] **Step 1: 编写 ExceptionListTable**
```javascript
// frontend/src/components/exceptions/ExceptionListTable.jsx
export default function ExceptionListTable({
items, selectedIds, onToggleSelect, onToggleAll,
isListLoading
}) {
if (isListLoading) {
return (
<div className="py-12 text-center text-slate-500">正在加载异常列表...</div>
);
}
if (!items.length) {
return (
<div className="py-12 text-center text-slate-500">当前筛选条件下没有异常记录</div>
);
}
return (
<table className="w-full border-separate border-spacing-y-2 text-left text-sm">
<thead className="sticky top-0 z-10 bg-slate-950/95 text-xs uppercase text-slate-500 backdrop-blur">
<tr>
<th className="w-12 px-4 py-3">
<input
type="checkbox"
className="rounded border-slate-700 bg-slate-950"
checked={selectedIds.length === items.length && items.length > 0}
onChange={(e) => onToggleAll(e.target.checked)}
disabled={!items.length}
/>
</th>
<th className="px-3 py-3 font-medium">异常文件</th>
<th className="px-3 py-3 font-medium">分类</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">原因</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const checked = selectedIds.includes(item.exception_id);
const statusColor = {
open: 'border-amber-500/30 bg-amber-500/10 text-amber-200',
resolved: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200',
ignored: 'border-slate-500/30 bg-slate-500/10 text-slate-400'
}[item.exception_resolution_status] || 'border-slate-500/30 bg-slate-500/10 text-slate-400';
const statusLabel = { open: '开放', resolved: '已解决', ignored: '已忽略' }[item.exception_resolution_status] || item.exception_resolution_status;
const typeColor = {
missing_tags: 'border-amber-500/20 bg-amber-500/5 text-amber-300',
duplicates: 'border-slate-500/20 bg-slate-500/5 text-slate-300',
match_failed: 'border-rose-500/20 bg-rose-500/5 text-rose-300',
low_score: 'border-amber-500/20 bg-amber-500/5 text-amber-300',
convert_failed: 'border-rose-500/20 bg-rose-500/5 text-rose-300',
organize_failed: 'border-rose-500/20 bg-rose-500/5 text-rose-300'
}[item.exception_type] || '';
return (
<tr key={item.exception_id} className="group">
<td className="px-4 py-3">
<input
type="checkbox"
className="rounded border-slate-700 bg-slate-950"
checked={checked}
onChange={() => onToggleSelect(item.exception_id)}
/>
</td>
<td className="px-3 py-3">
<div className="font-medium text-slate-100">{item.display_title}</div>
<div className="text-xs text-slate-500">{item.filename}</div>
</td>
<td className="px-3 py-3">
<span className={`rounded-full border px-2 py-0.5 text-[11px] ${typeColor}`}>
{item.type_label}
</span>
</td>
<td className="px-3 py-3">
<span className={`rounded-full border px-2 py-0.5 text-[11px] ${statusColor}`}>
{statusLabel}
</span>
</td>
<td className="px-3 py-3 max-w-[200px] truncate text-xs text-slate-400">
{item.display_reason}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
```
- [ ] **Step 2: 编写 ExceptionListView**
```javascript
// frontend/src/components/exceptions/ExceptionListView.jsx
import { AlertTriangle, Music2 } from 'lucide-react';
import { BULK_ACTIONS, actionButtonClass } from '../../utils/exceptions';
import ExceptionStatsBar from './ExceptionStatsBar';
import ExceptionTypeNav from './ExceptionTypeNav';
import ExceptionListTable from './ExceptionListTable';
import Pagination from '../Pagination';
export default function ExceptionListView({
summary, summaryError,
items, total, isListLoading, listError,
selectedIds, onToggleSelect, onToggleAll,
activeFilter, onFilterChange,
resolutionFilter, onResolutionChange,
currentPage, onPageChange,
bulkState, onBulkAction,
onSwitchToWizard
}) {
return (
<div className="relative flex h-[calc(100vh-120px)] flex-col gap-6 py-6 xl:flex-row">
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_left,_rgba(99,102,241,0.08),_transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] shadow-[0_24px_80px_rgba(2,6,23,0.45)] xl:basis-[66%]">
{/* Header */}
<div className="border-b border-slate-800/80 px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-indigo-300/70">异常决策台</p>
<h2 className="-mt-1 flex items-center gap-3 text-2xl font-semibold text-white">
<AlertTriangle className="h-6 w-6 text-rose-400" />
高级异常处理
</h2>
<p className="mt-2 max-w-2xl text-sm text-slate-400">
批量重复文件转码失败和入库失败在这里处理保留完整筛选与决策台能力
</p>
{summaryError ? <p className="mt-3 text-xs text-amber-300">{summaryError}</p> : null}
</div>
<div className="space-y-3">
<ExceptionStatsBar summary={summary} viewMode="advanced" />
<button onClick={onSwitchToWizard} className={actionButtonClass(true)}>
<Music2 className="h-3.5 w-3.5" />
返回单曲向导
</button>
</div>
</div>
<div className="mt-5">
<ExceptionTypeNav
activeFilter={activeFilter}
onFilterChange={(id) => { onFilterChange(id); onPageChange(1); }}
resolutionFilter={resolutionFilter}
onResolutionChange={(id) => { onResolutionChange(id); onPageChange(1); }}
summary={summary}
/>
</div>
{/* Bulk actions */}
{!bulkState.disabled && selectedIds.length > 0 && (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-indigo-900/40 bg-indigo-950/20 p-3">
<span className="text-xs text-indigo-200">已选 {selectedIds.length} </span>
{bulkState.actions.map((action) => (
<button
key={action}
onClick={() => onBulkAction(action)}
className="rounded-xl bg-indigo-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-400"
>
{{ retry_match: '一键匹配', ignore_exception: '全部忽略', retry_organize: '重试入库' }[action] || action}
</button>
))}
</div>
)}
</div>
{/* Table */}
<div className="min-h-0 flex-1 overflow-auto px-3 pb-4 pt-2">
<ExceptionListTable
items={items}
selectedIds={selectedIds}
onToggleSelect={onToggleSelect}
onToggleAll={onToggleAll}
isListLoading={isListLoading}
/>
{listError && (
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200 mt-4">
{listError}
</div>
)}
</div>
{/* Pagination */}
<div className="border-t border-slate-800/80 px-6 py-4">
<Pagination
currentPage={currentPage}
totalPages={Math.max(1, Math.ceil(total / 8))}
onPageChange={onPageChange}
/>
</div>
</section>
</div>
);
}
```
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/exceptions/ExceptionListTable.jsx frontend/src/components/exceptions/ExceptionListView.jsx
git commit -m "feat: add ExceptionListView and ExceptionListTable components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Phase 6: 重构 ExceptionPage 容器
### Task 6.1: 重写 ExceptionPage.jsx 为轻量容器
**Files:**
- Modify: `frontend/src/pages/ExceptionPage.jsx`
- Reference: `frontend/src/components/exceptions/ExceptionWizard.jsx`
- Reference: `frontend/src/components/exceptions/ExceptionListView.jsx`
- [ ] **Step 1: 用新 hooks 和组件重写 ExceptionPage.jsx**
`frontend/src/pages/ExceptionPage.jsx` 完整替换为以下内容:
```javascript
// frontend/src/pages/ExceptionPage.jsx
import { useState, useMemo, useCallback } from 'react';
import {
previewExceptionAction,
executeExceptionAction,
} from '../api/exceptions';
import { fetchRepairTask, fetchRepairTaskLogs } from '../api/repairs';
import {
BULK_ACTIONS, ITEMS_PER_PAGE, normalizeActionParams,
isMetadataWorkflowException, getMetadataQueueCounts, shouldRefreshExceptionListFully
} from '../utils/exceptions';
import useExceptionSummary from '../hooks/useExceptionSummary';
import useExceptionList from '../hooks/useExceptionList';
import useExceptionDetail from '../hooks/useExceptionDetail';
import useRepairTask from '../hooks/useRepairTask';
import useWizardState from '../hooks/useWizardState';
import ExceptionWizard from '../components/exceptions/ExceptionWizard';
import ExceptionListView from '../components/exceptions/ExceptionListView';
import MissingTagsInlinePanel from '../components/MissingTagsInlinePanel';
export default function ExceptionPage() {
// View mode
const [viewMode, setViewMode] = useState('wizard');
// Advanced view state
const [activeFilter, setActiveFilter] = useState('all');
const [resolutionFilter, setResolutionFilter] = useState('open');
const [currentPage, setCurrentPage] = useState(1);
const [selectedIds, setSelectedIds] = useState([]);
// Selected exception
const [selectedExceptionId, setSelectedExceptionId] = useState(null);
// Provider mode for retry_match
const [providerMode, setProviderMode] = useState('all');
const [providers, setProviders] = useState([]);
// Hooks
const { summary, error: summaryError, refresh: refreshSummary } = useExceptionSummary();
const {
items, total, metadataQueue, metadataTotal,
isListLoading, isMetadataQueueLoading,
listError, metadataQueueError,
setItems, setMetadataQueue,
refreshList, refreshMetadataQueue
} = useExceptionList({ viewMode, activeFilter, resolutionFilter, currentPage });
const {
detail: detailRecord, loading: isDetailLoading, error: detailError,
refresh: refreshDetail
} = useExceptionDetail(selectedExceptionId);
const {
repairTask, repairLogs,
executionStateByExceptionId,
registerExecution, setExecuting, setExecutionFailed,
completedRefreshRef,
setRepairTask, setRepairLogs
} = useRepairTask();
const {
wizardStep, setWizardStep,
selectedAction, setSelectedAction,
actionParams, setActionParams,
previewState, setPreviewState,
executeError, setExecuteError,
initForDetail, focusAction
} = useWizardState('select');
// Derived
const metadataQueueCounts = useMemo(() => getMetadataQueueCounts(metadataQueue), [metadataQueue]);
const detailExecutionState = detailRecord ? executionStateByExceptionId[detailRecord.exception_id] || null : null;
// Bulk state
const bulkState = useMemo(() => {
if (!selectedIds.length) return { disabled: true, reason: '', actions: [] };
const selectedItems = items.filter((item) => selectedIds.includes(item.exception_id));
const types = [...new Set(selectedItems.map((item) => item.exception_type))];
if (types.length !== 1) return { disabled: true, reason: '混合异常类型不能批量处理', actions: [] };
const actions = BULK_ACTIONS[types[0]] || [];
return { disabled: !actions.length, reason: actions.length ? '' : '当前类型不支持批量动作', actions };
}, [items, selectedIds]);
// Handlers
const selectWizardItem = useCallback((exceptionId) => {
setSelectedExceptionId(exceptionId);
setWizardStep('listen');
setSelectedIds([]);
}, [setWizardStep]);
const switchToAdvanced = useCallback(() => {
setViewMode('advanced');
setSelectedIds([]);
refreshList();
}, [refreshList]);
const switchToWizard = useCallback(() => {
setViewMode('wizard');
setSelectedIds([]);
setWizardStep(selectedExceptionId ? 'listen' : 'select');
}, [selectedExceptionId, setWizardStep]);
const handleToggleSelect = useCallback((id) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
}, []);
const handleToggleAll = useCallback((checked) => {
setSelectedIds(checked ? items.map((item) => item.exception_id) : []);
}, [items]);
const handlePreview = useCallback(async (action) => {
const exceptionIds = selectedIds.length ? selectedIds : detailRecord ? [detailRecord.exception_id] : [];
if (!exceptionIds.length || !action) return;
setPreviewState({ loading: true, payload: null, error: '', action });
setExecuteError('');
try {
const payload = await previewExceptionAction({
exception_ids: exceptionIds, action,
params: normalizeActionParams(action, actionParams)
});
setPreviewState({ loading: false, payload, error: '', action });
} catch (err) {
setPreviewState({ loading: false, payload: null, error: err.message || '预览失败', action });
}
}, [selectedIds, detailRecord, actionParams, setPreviewState, setExecuteError]);
const handleExecute = useCallback(async (action) => {
const exceptionIds = selectedIds.length ? selectedIds : detailRecord ? [detailRecord.exception_id] : [];
if (!exceptionIds.length || !action) return;
if (action === 'delete_file' &&
(!window.confirm('将永久删除选中的文件,且无法恢复。是否继续?') ||
!window.confirm('请再次确认:这会真实删除文件,不是忽略。是否执行删除?'))
) return;
const isSingleItem = exceptionIds.length === 1;
const currentExceptionId = isSingleItem ? detailRecord?.exception_id : null;
setExecuteError('');
if (currentExceptionId) {
setExecuting(currentExceptionId, action, previewState.payload);
}
try {
const payload = await executeExceptionAction({
exception_ids: exceptionIds, action,
params: normalizeActionParams(action, actionParams)
});
if (currentExceptionId) {
registerExecution(currentExceptionId, action, payload.repair_task_id, previewState.payload);
}
const tp = await fetchRepairTask(payload.repair_task_id);
const lp = await fetchRepairTaskLogs(payload.repair_task_id, 1, 20);
setRepairTask(tp.task);
setRepairLogs(lp.logs);
} catch (err) {
if (currentExceptionId) {
setExecutionFailed(currentExceptionId, action, err.message || '执行失败', previewState.payload);
}
setExecuteError(err.message || '执行失败');
}
}, [selectedIds, detailRecord, actionParams, previewState, setExecuting, registerExecution, setExecutionFailed, setRepairTask, setRepairLogs, setExecuteError]);
const handleUpdateMetadata = useCallback((key, value) => {
setActionParams((prev) => ({
...prev,
metadata_patch: { ...(prev.metadata_patch || {}), [key]: value }
}));
setPreviewState({ loading: false, payload: null, error: '', action: '' });
}, [setActionParams, setPreviewState]);
const handleBulkAction = useCallback(async (action) => {
if (!selectedIds.length || !action) return;
if (action === 'delete_file' &&
(!window.confirm('将永久删除选中文件,无法恢复。是否继续?') ||
!window.confirm('请再次确认:是否执行删除?'))
) return;
try {
setPreviewState({ loading: true, payload: null, error: '', action });
const payload = await previewExceptionAction({
exception_ids: selectedIds, action, params: {}
});
setPreviewState({ loading: false, payload, error: '', action });
// Execute immediately for bulk
const execPayload = await executeExceptionAction({
exception_ids: selectedIds, action, params: {}
});
const tp = await fetchRepairTask(execPayload.repair_task_id);
const lp = await fetchRepairTaskLogs(execPayload.repair_task_id, 1, 20);
setRepairTask(tp.task);
setRepairLogs(lp.logs);
} catch (err) {
setPreviewState({ loading: false, payload: null, error: err.message || '批量操作失败', action });
}
}, [selectedIds, setPreviewState, setRepairTask, setRepairLogs]);
// Refresh on repair task completion
const handleRepairComplete = useCallback(() => {
if (repairTask?.task_id && completedRefreshRef.current.has(repairTask.task_id)) {
refreshSummary();
if (shouldRefreshExceptionListFully(repairTask)) {
refreshDetail();
refreshList();
} else {
refreshDetail();
refreshList();
}
refreshMetadataQueue();
}
}, [repairTask?.task_id]);
// Render
if (viewMode === 'wizard' && detailRecord?.exception_type === 'missing_tags') {
return <MissingTagsInlinePanel onSwitchToAdvanced={switchToAdvanced} />;
}
if (viewMode === 'wizard') {
return (
<ExceptionWizard
summary={summary}
metadataQueue={metadataQueue}
metadataTotal={metadataTotal}
metadataQueueCounts={metadataQueueCounts}
isMetadataQueueLoading={isMetadataQueueLoading}
metadataQueueError={metadataQueueError}
selectedExceptionId={selectedExceptionId}
onSelectItem={selectWizardItem}
detailRecord={detailRecord}
isDetailLoading={isDetailLoading}
detailError={detailError}
wizardStep={wizardStep}
onSetWizardStep={setWizardStep}
selectedAction={selectedAction}
actionParams={actionParams}
providerMode={providerMode}
setProviderMode={setProviderMode}
providers={providers}
setProviders={setProviders}
previewState={previewState}
executionState={detailExecutionState}
repairTask={repairTask}
repairLogs={repairLogs}
onFocusAction={focusAction}
onPreview={handlePreview}
onExecute={handleExecute}
onUpdateMetadata={handleUpdateMetadata}
onSwitchToAdvanced={switchToAdvanced}
/>
);
}
return (
<ExceptionListView
summary={summary}
summaryError={summaryError}
items={items}
total={total}
isListLoading={isListLoading}
listError={listError}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
onToggleAll={handleToggleAll}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
resolutionFilter={resolutionFilter}
onResolutionChange={setResolutionFilter}
currentPage={currentPage}
onPageChange={setCurrentPage}
bulkState={bulkState}
onBulkAction={handleBulkAction}
onSwitchToWizard={switchToWizard}
/>
);
}
```
- [ ] **Step 2: 验证构建**
```bash
cd frontend && npm run build
```
Expected: PASS (no errors)
- [ ] **Step 3: 验证开发服务器启动**
```bash
cd frontend && npm run dev &
sleep 3
curl -s http://localhost:5173 | head -20
```
Expected: HTML response with React app
- [ ] **Step 4: Commit**
```bash
git add frontend/src/pages/ExceptionPage.jsx
git commit -m "refactor: rewrite ExceptionPage as lightweight container with hooks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Phase 7: 视觉样式和动画
### Task 7.1: 应用深色专业主题全局过渡样式
**Files:**
- Create: `frontend/src/styles/exceptions.css`
- Modify: `frontend/src/main.jsx` (import css)
- [ ] **Step 1: 创建异常中心专用样式**
```css
/* frontend/src/styles/exceptions.css */
/* Step transition */
.exception-wizard-step-enter {
opacity: 0;
transform: translateX(8px);
}
.exception-wizard-step-enter-active {
opacity: 1;
transform: translateX(0);
transition: opacity 150ms ease-out, transform 150ms ease-out;
}
/* List item hover */
.exception-list-item {
transition: background-color 150ms ease;
}
.exception-list-item:hover {
background-color: rgba(51, 65, 85, 0.5);
}
/* Stat card hover lift */
.exception-stat-card {
transition: transform 150ms ease, box-shadow 150ms ease;
}
.exception-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
}
/* Button hover deepen */
.exception-btn-primary {
transition: background-color 150ms ease, filter 150ms ease;
}
.exception-btn-primary:hover {
filter: brightness(1.1);
}
/* Skeleton loading */
@keyframes exception-skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.exception-skeleton {
animation: exception-skeleton-pulse 1.5s ease-in-out infinite;
background: linear-gradient(90deg, #1e293b 25%, #334155 50%, #1e293b 75%);
background-size: 200% 100%;
border-radius: 8px;
}
/* Modal backdrop */
.exception-modal-backdrop {
animation: exception-fade-in 200ms ease-out;
}
@keyframes exception-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.exception-modal-content {
animation: exception-scale-in 200ms ease-out;
}
@keyframes exception-scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
/* Scrollbar styling for dark theme */
.exception-scrollbar::-webkit-scrollbar {
width: 6px;
}
.exception-scrollbar::-webkit-scrollbar-track {
background: #0f172a;
}
.exception-scrollbar::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
.exception-scrollbar::-webkit-scrollbar-thumb:hover {
background: #475569;
}
```
- [ ] **Step 2: 在 main.jsx 中导入样式**
`frontend/src/main.jsx` 文件末尾添加:
```javascript
import './styles/exceptions.css';
```
- [ ] **Step 3: Commit**
```bash
git add frontend/src/styles/exceptions.css frontend/src/main.jsx
git commit -m "feat: add dark professional theme transitions and animations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Phase 8: 最终验证
### Task 8.1: 全量构建和 lint 检查
- [ ] **Step 1: 运行 ESLint**
```bash
cd frontend && npm run lint
```
Expected: PASS or pre-existing warnings only (no new errors)
- [ ] **Step 2: 运行生产构建**
```bash
cd frontend && npm run build
```
Expected: Build successful, no errors
- [ ] **Step 3: 检查构建输出大小**
```bash
ls -lh frontend/dist/assets/*.js | head -5
```
Expected: Files generated (note sizes for comparison)
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "chore: final verification - lint and build pass
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## 验证清单
- [ ] `npm run build` 成功,无错误
- [ ] `npm run lint` 无新增错误
- [ ] 向导模式 5 步流程可正常工作(手动测试)
- [ ] 高级列表模式可正常筛选、分页、批量操作
- [ ] MissingTagsInlinePanel 不受影响(旧 `missing_tags` 快捷处理)
- [ ] WebSocket 修复任务实时更新正常
- [ ] 音频试听播放正常
- [ ] 所有 API 调用参数格式不变
---
## 风险与回滚
- 如果新组件出现未预期行为,可临时在 `App.jsx` 中将 `/exceptions` 路由回旧版 `ExceptionPage.jsx.backup`
- 每个 Phase 独立提交,可在任意阶段回滚
- 后端 API 不变,前端数据流逻辑不变,回归风险低