be3c086975
- 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>
2780 lines
103 KiB
Markdown
2780 lines
103 KiB
Markdown
# 异常中心页面优化 — 实施计划
|
||
|
||
> **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 不变,前端数据流逻辑不变,回归风险低
|