From be3c086975f6e01c41b8f34e662658d30f3fc59f Mon Sep 17 00:00:00 2001 From: liumangmang Date: Fri, 8 May 2026 15:49:37 +0800 Subject: [PATCH] chore: add project configs, backend repair services, docs, and code quality tooling - Add pre-commit hooks (ruff, black, prettier) and ESLint/Prettier configs - Add backend repair services (execution, orchestration, preview) with tests - Add project documentation (CLAUDE.md, README.md, design specs and plans) - Add MissingTagsInlinePanel component for exception handling - Add pyproject.toml with ruff/black configuration Co-Authored-By: Claude Opus 4.6 --- .pre-commit-config.yaml | 28 + CLAUDE.md | 102 + README.md | 145 + backend/app/services/repair_execution.py | 248 ++ backend/app/services/repair_orchestrator.py | 110 + backend/app/services/repair_preview.py | 243 ++ backend/pyproject.toml | 41 + backend/tests/test_repair_preview.py | 159 + .../2026-05-07-exception-center-redesign.md | 2779 +++++++++++++++++ .../plans/2026-05-07-workbench-refactor.md | 1069 +++++++ .../2026-05-07-exception-center-redesign.md | 250 ++ .../2026-05-07-workbench-refactor-design.md | 157 + ...026-05-08-settings-page-refactor-design.md | 139 + frontend/.eslintrc.json | 1 + frontend/.prettierignore | 8 + frontend/.prettierrc | 1 + .../src/components/MissingTagsInlinePanel.jsx | 909 ++++++ 17 files changed, 6389 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 backend/app/services/repair_execution.py create mode 100644 backend/app/services/repair_orchestrator.py create mode 100644 backend/app/services/repair_preview.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/test_repair_preview.py create mode 100644 docs/superpowers/plans/2026-05-07-exception-center-redesign.md create mode 100644 docs/superpowers/plans/2026-05-07-workbench-refactor.md create mode 100644 docs/superpowers/specs/2026-05-07-exception-center-redesign.md create mode 100644 docs/superpowers/specs/2026-05-07-workbench-refactor-design.md create mode 100644 docs/superpowers/specs/2026-05-08-settings-page-refactor-design.md create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/src/components/MissingTagsInlinePanel.jsx diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e92a7b6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.0 + hooks: + - id: ruff + args: [ --fix, --exit-non-zero-on-fix ] + files: ^backend/ + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 24.2.0 + hooks: + - id: black + files: ^backend/ + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + files: ^frontend/ + types_or: [javascript, jsx, ts, tsx, json, css, md] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f358f7a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。 + +## 常用命令 + +### 后端 (Python/FastAPI) + +```bash +cd backend + +# 安装依赖(使用虚拟环境) +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt # 最小运行环境 +# 或完整开发环境:pip install ruff black pytest + +# 运行开发服务器 +uvicorn app.main:app --reload --port 8000 + +# 代码检查与格式化 +ruff check . # 代码检查 +black . # 格式化 + +# 运行测试 +pytest # 所有测试 +pytest tests/test_matcher.py -v # 单个测试文件 +pytest -k "test_function_name" # 按名称运行单个测试 +``` + +### 前端 (React/Vite) + +```bash +cd frontend + +npm install +npm run dev # 启动开发服务器(端口 5173) +npm run build # 生产构建 +npm run preview # 预览构建产物 +npm run lint # ESLint 检查 +npm run lint:fix # 自动修复 lint 问题 +npm run format # Prettier 格式化 +``` + +### 全栈开发 + +```bash +# 使用提供的脚本同时启动后端和前端 +./scripts/dev.sh start # 启动两者,日志输出到 .dev-runtime/ +./scripts/dev.sh stop # 停止两者 +./scripts/dev.sh restart +./scripts/dev.sh status +``` + +### Pre-commit 钩子 + +```bash +pre-commit install +pre-commit run --all-files +``` + +## 架构概览 + +### 后端核心 (`backend/app/`) + +- **`main.py`** – FastAPI 应用,包含 CORS、WebSocket 端点 (`/ws/tasks/{task_id}`)、配置/任务/媒体库/异常/修复的 REST 路由。 +- **`task_runner.py`** – 编排异步流程:扫描 → 预处理 → 匹配 → 后处理。通过 `TaskStreamManager` 发送进度。 +- **`repair_runner.py`** – 处理异常修复工作流(预览/执行),用于元数据错误或缺失的条目。 +- **`matcher.py`** – 多源元数据匹配(AcoustID、MusicBrainz、网易云、QQ 音乐、Spotify)。包含 provider 链式逻辑。 +- **`preprocessor.py`** – 使用 Mutagen 从音频文件提取/清洗元数据。 +- **`scanner.py`** – 递归扫描输入目录中的音频文件。 +- **`library_service.py`** / **`library_postprocess.py`** / **`library_index.py`** – 管理整理后的媒体库:将文件移动/复制到 `Artist/Album/` 结构,维护索引。 +- **`exception_service.py`** – 处理匹配失败或有冲突的音轨,与主库分开存储。 +- **`task_store.py`** / **`storage.py`** – SQLite 持久化,存储任务历史、配置、媒体库索引。使用原生 SQL 管理 Schema。 +- **`task_stream.py`** – WebSocket 消息广播,用于实时任务更新。 +- **`services/`** – 拆分出的修复逻辑:`repair_preview.py`、`repair_execution.py`、`repair_orchestrator.py`。 +- **`schemas.py`** – Pydantic 模型,定义 API 请求/响应结构。 + +### 前端 (`frontend/src/`) + +- **`pages/`** – 主视图(仪表盘、媒体库、异常、配置等)。 +- **`components/`** – 可复用 UI 组件(模态框、卡片、表单、任务进度)。 +- **`api/`** – Axios 客户端封装后端 API 调用;WebSocket 辅助函数用于实时日志。 +- **`utils/`** – 格式化、日期辅助、常量。 +- **`App.jsx`** – 路由配置(React Router)。 +- **`main.jsx`** – 入口文件,集成 Tailwind CSS。 + +### 其他目录 + +- **`services/metadata/`** – 外部元数据 provider 集成(MusicBrainz 等 API 客户端)。 +- **`scripts/dev.sh`** – 后台运行 uvicorn + npm run dev 的进程管理脚本。 +- **`.pre-commit-config.yaml`** – 对暂存文件运行 ruff、black、prettier。 + +## 关键环境变量 + +- `MUSIC_WORKSHOP_DB_PATH` – 覆盖 SQLite 数据库路径(默认 `backend/data/music_workshop.db`)。 +- 各元数据 provider 的 API 密钥:在 `backend/.env` 中设置(参考 `.env.example`)。 + +## 测试说明 + +- 后端测试使用 `pytest`;测试固件直接写在测试文件中(暂无单独的 `conftest.py`)。 +- 运行单个测试示例:`pytest tests/test_matcher.py::test_specific_function` +- 前端测试尚未配置(`package.json` 中没有 `npm test` 脚本)。 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..29ef726 --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# Music Workshop + +音乐文件智能整理工具 - 通过元数据匹配、去重、自动入库,将散乱的音乐文件整理成规范的媒体库。 + +## 功能特性 + +- 🎵 **元数据匹配**:支持 AcoustID、MusicBrainz、网易云、QQ 音乐、Spotify 等多源匹配 +- 🔧 **缺失元数据补全**:可视化编辑 title/artist/album_artist,实时预览入库路径 +- 🗑️ **智能去重**:基于音频指纹和元数据的重复检测,支持保留/替换/重命名策略 +- 📂 **自动整理入库**:按 `Artist/Album/Track - Title.ext` 规范路径组织文件 +- 🖥️ **Web UI**:React + Tailwind CSS,提供异常队列处理和批量操作界面 +- 📊 **任务跟踪**:实时 WebSocket 推送,查看任务执行进度和日志 + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 前端 | React 18, Vite, Tailwind CSS, Lucide Icons | +| 后端 | Python 3.11+, FastAPI, SQLite, Mutagen | +| 匹配引擎 | AcoustID, MusicBrainz, 网易云 API, QQ 音乐 API, Spotify API | +| 音频处理 | FFmpeg, Chromaprint (音频指纹) | + +## 项目结构 + +``` +MusicWorkshop/ +├── backend/ # FastAPI 后端服务 +│ ├── app/ +│ │ ├── services/ # 拆分后的服务模块(预览/执行/编排) +│ │ ├── exception_service.py +│ │ ├── matcher.py +│ │ ├── preprocessor.py +│ │ ├── repair_runner.py +│ │ └── ... +│ ├── tests/ # 单元测试 +│ └── pyproject.toml # ruff/black 配置 +├── frontend/ # React 前端应用 +│ ├── src/ +│ │ ├── api/ # API 调用封装 +│ │ ├── components/ # 可复用组件 +│ │ └── pages/ # 页面组件 +│ ├── .eslintrc.json # ESLint 配置 +│ ├── .prettierrc # Prettier 配置 +│ └── package.json +├── scripts/ # 辅助脚本 +├── .pre-commit-config.yaml # Git hooks 配置 +└── README.md +``` + +## 快速开始 + +### 环境要求 + +- Node.js 18+ +- Python 3.11+ +- FFmpeg (用于音频处理) +- Chromaprint (用于音频指纹) + +### 安装依赖 + +```bash +# 后端 +cd backend +pip install -r requirements.txt + +# 前端 +cd frontend +npm install +``` + +### 配置 + +1. 复制 `backend/.env.example` 为 `backend/.env` +2. 填写 API 密钥(AcoustID、MusicBrainz、网易云等) +3. 设置输入/输出目录路径 + +### 启动服务 + +```bash +# 后端 (开发模式) +cd backend +uvicorn app.main:app --reload --port 8000 + +# 前端 (开发模式) +cd frontend +npm run dev +``` + +访问 `http://localhost:5173` 打开 Web UI。 + +## 代码规范 + +本项目使用以下工具保证代码质量: + +### 前端 + +```bash +cd frontend +npm run lint # 检查代码规范 +npm run lint:fix # 自动修复 +npm run format # 格式化代码 +``` + +### 后端 + +```bash +cd backend +ruff check . # 检查代码规范 +black . # 格式化代码 +``` + +### Git Hooks (pre-commit) + +```bash +pre-commit install # 安装 Git hooks +pre-commit run --all-files # 手动运行所有检查 +``` + +## API 文档 + +启动后端服务后访问 `http://localhost:8000/docs` 查看自动生成的 OpenAPI 文档。 + +## 测试 + +```bash +# 后端单元测试 +cd backend +pytest + +# 前端 (待补充) +cd frontend +npm test +``` + +## 许可证 + +[MIT](LICENSE) + +## 贡献指南 + +欢迎提交 Issue 和 Pull Request。请确保代码通过 lint 检查和单元测试。 + +--- + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/backend/app/services/repair_execution.py b/backend/app/services/repair_execution.py new file mode 100644 index 0000000..345de7d --- /dev/null +++ b/backend/app/services/repair_execution.py @@ -0,0 +1,248 @@ +from pathlib import Path + +from backend.app.exception_service import ExceptionService +from backend.app.library_postprocess import ( + _build_prefixed_name, + _build_quality_breakdown, + _build_unique_destination, + _serialize_compared_candidate, +) +from backend.app.metadata_normalization import MetadataNormalizationService, can_ingest_metadata +from backend.app.task_constants import current_timestamp + + +class RepairExecutionError(Exception): + def __init__(self, reason: str, message: str): + super().__init__(message) + self.reason = reason + self.message = message + + +class RepairExecutionService: + def __init__( + self, + task_store, + exception_service: ExceptionService, + metadata_normalizer: MetadataNormalizationService, + organize_service, + match_service, + preprocess_service, + dedupe_service, + ): + self.task_store = task_store + self.exception_service = exception_service + self.metadata_normalizer = metadata_normalizer + self.organize_service = organize_service + self.match_service = match_service + self.preprocess_service = preprocess_service + self.dedupe_service = dedupe_service + + def apply_action_to_item( + self, repair_task_id: str, exception_id: int, action: str, params: dict, config_snapshot: dict, stats: dict + ) -> dict: + item = self.task_store.get_exception_source_item(exception_id) + if item is None: + raise RepairExecutionError('item_missing', f'异常项不存在: {exception_id}') + before_snapshot = self.exception_service.get_item(exception_id) + final_item = item + execution_result = {'action': action, 'status': 'completed'} + + if action == 'ignore_exception': + stats['execute']['ignored_items'] += 1 + resolution_status = 'ignored' + workflow_state = 'ignored' + elif action == 'edit_metadata': + final_item, changed = self._apply_metadata_patch(item, params.get('metadata_patch') or {}) + if changed: + stats['execute']['updated_metadata_items'] += 1 + resolution_status = 'open' + workflow_state = self._metadata_workflow_state(final_item, params.get('metadata_patch') or {}) + elif action == 'retry_match': + final_item = self.match_service.retry_match(item, config_snapshot, providers=params.get('providers') or None) + resolution_status = 'open' + workflow_state = self._metadata_workflow_state(final_item) + elif action == 'select_match_candidate': + final_item = self.match_service.select_candidate(item, int(params.get('candidate_index', -1))) + resolution_status = 'open' + workflow_state = self._candidate_workflow_state(final_item) + elif action == 'retry_preprocess': + final_item = self.preprocess_service.retry_preprocess(item) + resolution_status = 'resolved' if final_item.get('preprocess_status') != 'failed' else 'open' + workflow_state = 'ingested' if resolution_status == 'resolved' else 'open' + elif action == 'move_to_review_trash': + trashed_path = self.organize_service.move_to_review_trash( + trash_root=config_snapshot['trash'], + task_id=repair_task_id, + item_id=item['id'], + source_path=item['current_file_path'], + reason='manual_review', + ) + final_item = self.task_store.update_task_item( + item['id'], + is_active=0, + current_file_path=trashed_path, + trash_file_path=trashed_path, + organize_status='trashed', + organize_reason='manual_review', + organize_message='已移入 review trash', + ) + stats['execute']['moved_items'] += 1 + resolution_status = 'resolved' + workflow_state = 'ingested' + elif action == 'keep_existing': + final_item = self.dedupe_service.keep_existing(item, task_id=repair_task_id, trash_root=config_snapshot['trash']) + stats['execute']['moved_items'] += 1 + resolution_status = 'resolved' + workflow_state = 'ingested' + elif action == 'replace_existing': + final_item, execution_result = self.dedupe_service.replace_existing( + item, task_id=repair_task_id, output_root=config_snapshot['output'], trash_root=config_snapshot['trash'] + ) + stats['execute']['moved_items'] += 1 + resolution_status = 'resolved' + workflow_state = 'ingested' + elif action == 'keep_both_with_rename': + final_item, execution_result = self.dedupe_service.keep_both_with_rename(item, output_root=config_snapshot['output']) + stats['execute']['moved_items'] += 1 + resolution_status = 'resolved' + workflow_state = 'ingested' + elif action == 'retry_organize': + final_item, execution_result = self.organize_service.organize_item( + item, output_root=config_snapshot['output'], override_relative_path=params.get('target_relative_path') + ) + stats['execute']['moved_items'] += 1 + resolution_status = 'resolved' + workflow_state = 'ingested' + elif action == 'save_and_organize': + patched_item, changed = self._apply_metadata_patch(item, params.get('metadata_patch') or {}) + if changed: + stats['execute']['updated_metadata_items'] += 1 + if not can_ingest_metadata(self.metadata_normalizer.normalize_item(patched_item)): + raise RepairExecutionError('metadata_incomplete', '加入音乐库前必须补齐 title、artist、album_artist') + final_item, execution_result = self.organize_service.organize_item(patched_item, output_root=config_snapshot['output']) + stats['execute']['moved_items'] += 1 + resolution_status = 'resolved' + workflow_state = 'ingested' + elif action == 'delete_file': + file_path = Path(item['current_file_path']) + if not file_path.exists(): + raise RepairExecutionError('source_missing', f'源文件不存在: {file_path}') + file_path.unlink() + final_item = self.task_store.update_task_item( + item['id'], + is_active=0, + organize_status='deleted', + organize_reason='manual_delete', + organize_message='文件已被永久删除', + ) + resolution_status = 'resolved' + workflow_state = 'deleted' + else: + raise ValueError(f'Unsupported action: {action}') + + after_snapshot = self._build_after_snapshot(before_snapshot, final_item, resolution_status) + self._update_resolution(final_item, resolution_status, workflow_state, after_snapshot, execution_result, repair_task_id, params) + return final_item + + def _apply_metadata_patch(self, item: dict, metadata_patch: dict) -> tuple[dict, bool]: + patch = {key: value for key, value in (metadata_patch or {}).items() if value is not None} + if not patch: + return item, False + + normalized_metadata = self.metadata_normalizer.normalize_item(item, patch) + merged_tags = dict(item.get('original_tags_json') or {}) + merged_tags.update({key: value for key, value in normalized_metadata.items() if key in self._writable_keys()}) + + file_path = Path(item['current_file_path']) + if file_path.exists(): + self._write_tags(file_path, merged_tags) + + updated_item = self.task_store.update_task_item( + item['id'], original_tags_json=merged_tags, matched_metadata_json=normalized_metadata + ) + return updated_item, True + + def _write_tags(self, file_path: Path, tags: dict): + import mutagen + + tags_file = mutagen.File(str(file_path), easy=True) + if tags_file is None: + raise RepairExecutionError('metadata_write_failed', f'无法写入标签: {file_path}') + + key_mapping = { + 'title': 'title', + 'artist': 'artist', + 'album': 'album', + 'album_artist': 'albumartist', + 'track_number': 'tracknumber', + 'disc_number': 'discnumber', + 'year': 'date', + 'lyrics': 'lyrics', + } + for source_key, target_key in key_mapping.items(): + if source_key not in tags: + continue + value = tags[source_key] + if value in (None, ''): + continue + tags_file[target_key] = [str(value)] + tags_file.save() + + def _writable_keys(self): + return {'title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics'} + + def _build_after_snapshot(self, before_snapshot: dict, final_item: dict, resolution_status: str) -> dict: + if resolution_status == 'open': + return self.exception_service.get_item(before_snapshot.get('exception_id')) + return { + **before_snapshot, + 'current_file_path': final_item.get('current_file_path'), + 'trash_file_path': final_item.get('trash_file_path'), + 'library_relative_path': final_item.get('library_relative_path'), + 'library_file_path': final_item.get('library_file_path'), + 'matched_metadata_json': final_item.get('matched_metadata_json'), + 'original_tags_json': final_item.get('original_tags_json'), + } + + def _update_resolution( + self, + final_item: dict, + resolution_status: str, + workflow_state: str, + after_snapshot: dict, + execution_result: dict, + repair_task_id: str, + params: dict, + ): + resolution = dict(final_item.get('exception_resolution_json') or {}) + resolution.update( + { + 'resolved_at': current_timestamp(), + 'workflow_state': workflow_state, + 'metadata_draft': self._build_metadata_draft(final_item, params.get('metadata_patch') or {}, workflow_state), + 'after_snapshot': after_snapshot, + 'execution_result': execution_result, + } + ) + self.task_store.update_task_item( + final_item['id'], + exception_resolution_status=resolution_status, + exception_resolution_json=resolution, + last_repair_task_id=repair_task_id, + ) + + def _metadata_workflow_state(self, item: dict, metadata_patch: dict | None = None) -> str: + metadata = item.get('matched_metadata_json') or {} + if metadata_patch: + metadata = {**metadata, **{key: value for key, value in metadata_patch.items() if value is not None}} + return 'ready_to_ingest' if can_ingest_metadata(metadata) else 'open' + + def _candidate_workflow_state(self, item: dict) -> str: + return 'ready_to_ingest' if can_ingest_metadata(item.get('matched_metadata_json') or {}) else 'candidate_selected' + + def _build_metadata_draft(self, item: dict, metadata_patch: dict | None, workflow_state: str) -> dict | None: + if workflow_state not in {'candidate_selected', 'ready_to_ingest'} and not metadata_patch: + return None + metadata = dict(item.get('matched_metadata_json') or {}) + metadata.update({key: value for key, value in (metadata_patch or {}).items() if value is not None}) + return metadata diff --git a/backend/app/services/repair_orchestrator.py b/backend/app/services/repair_orchestrator.py new file mode 100644 index 0000000..73c4506 --- /dev/null +++ b/backend/app/services/repair_orchestrator.py @@ -0,0 +1,110 @@ +from backend.app.task_constants import ( + STAGE_STATUS_COMPLETED, + STAGE_STATUS_FAILED, + STAGE_STATUS_RUNNING, + TASK_STATUS_COMPLETED, + TASK_STATUS_FAILED, + TASK_STATUS_RUNNING, + current_timestamp, + create_empty_repair_stats, + create_pending_repair_stage_states, +) +from backend.app.services.repair_execution import RepairExecutionError, RepairExecutionService +from backend.app.services.repair_preview import RepairPreviewService + + +class RepairOrchestrator: + def __init__(self, task_store, task_stream, preview_service: RepairPreviewService, execution_service: RepairExecutionService): + self.task_store = task_store + self.task_stream = task_stream + self.preview_service = preview_service + self.execution_service = execution_service + + def start_task(self, repair_task_id: str, config_snapshot: dict): + task = self.task_store.get_task(repair_task_id) + plan = task.get('repair_plan_json') or {} + stats = create_empty_repair_stats() + stage_states = create_pending_repair_stage_states() + + try: + stage_states['prepare'] = STAGE_STATUS_RUNNING + self.task_store.update_task( + repair_task_id, + status=TASK_STATUS_RUNNING, + current_stage='prepare', + stage_states=stage_states, + stats=stats, + ) + self._log(repair_task_id, 'prepare', 'info', 'stage.started', '开始准备 repair 执行') + stats['prepare']['previewed_items'] = len(plan.get('items') or []) + stage_states['prepare'] = STAGE_STATUS_COMPLETED + stage_states['execute'] = STAGE_STATUS_RUNNING + self.task_store.update_task( + repair_task_id, + status=TASK_STATUS_RUNNING, + current_stage='execute', + stage_states=stage_states, + stats=stats, + ) + self._broadcast(repair_task_id, 'stage.completed', 'prepare', {'stats': stats}) + + for exception_id in plan.get('items') or []: + try: + self.execution_service.apply_action_to_item( + repair_task_id, exception_id, plan['action'], plan.get('params') or {}, config_snapshot, stats + ) + stats['execute']['succeeded_items'] += 1 + except Exception as error: + stats['execute']['failed_items'] += 1 + item = self.task_store.get_exception_source_item(exception_id) + if item: + resolution = dict(item.get('exception_resolution_json') or {}) + resolution['resolved_at'] = current_timestamp() + resolution['execution_result'] = {'status': 'failed', 'message': str(error)} + self.task_store.update_task_item( + exception_id, exception_resolution_status='open', exception_resolution_json=resolution + ) + self._log( + repair_task_id, + 'execute', + 'error', + 'repair.item_failed', + f'异常项执行失败: {exception_id}', + {'exception_id': exception_id, 'error': str(error)}, + ) + + stage_states['execute'] = STAGE_STATUS_COMPLETED + stage_states['complete'] = STAGE_STATUS_COMPLETED + completed_at = current_timestamp() + self.task_store.update_task( + repair_task_id, + status=TASK_STATUS_COMPLETED, + current_stage='complete', + stage_states=stage_states, + stats=stats, + completed_at=completed_at, + ) + self._broadcast(repair_task_id, 'task.completed', 'complete', {'stats': stats}) + self._log(repair_task_id, 'complete', 'success', 'task.completed', 'repair 任务已完成', {'stats': stats}) + except Exception as error: + stage_states['prepare'] = STAGE_STATUS_FAILED if stage_states['prepare'] == STAGE_STATUS_RUNNING else stage_states['prepare'] + stage_states['execute'] = STAGE_STATUS_FAILED if stage_states['execute'] == STAGE_STATUS_RUNNING else stage_states['execute'] + stage_states['complete'] = STAGE_STATUS_FAILED + self.task_store.update_task( + repair_task_id, + status=TASK_STATUS_FAILED, + current_stage='execute', + stage_states=stage_states, + stats=stats, + error_message=str(error), + completed_at=current_timestamp(), + ) + self._broadcast(repair_task_id, 'task.failed', 'execute', {'error_message': str(error), 'stats': stats}) + self._log(repair_task_id, 'execute', 'error', 'task.failed', f'repair 任务失败: {error}', {'error': str(error)}) + + def _log(self, task_id: str, stage: str, level: str, event_type: str, message: str, payload: dict | None = None): + persisted_log = self.task_store.append_log(task_id, stage, level, event_type, message, payload) + self._broadcast(task_id, 'log.appended', stage, {'log': persisted_log}) + + def _broadcast(self, task_id: str, event_type: str, stage: str, data: dict): + self.task_stream.broadcast_event(task_id, event_type, stage, data) diff --git a/backend/app/services/repair_preview.py b/backend/app/services/repair_preview.py new file mode 100644 index 0000000..68cb0fb --- /dev/null +++ b/backend/app/services/repair_preview.py @@ -0,0 +1,243 @@ +from pathlib import Path +from typing import Any + +from backend.app.exception_service import ExceptionService +from backend.app.metadata_normalization import MetadataNormalizationService, can_ingest_metadata +from backend.app.task_store import TaskStore + + +METADATA_PREVIEW_FIELDS = ( + 'title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics' +) + + +class RepairPreviewService: + def __init__( + self, + task_store: TaskStore, + exception_service: ExceptionService, + metadata_normalizer: MetadataNormalizationService, + organize_service, + ): + self.task_store = task_store + self.exception_service = exception_service + self.metadata_normalizer = metadata_normalizer + self.organize_service = organize_service + + def preview(self, payload: dict, config_snapshot: dict) -> dict: + items = self._load_exception_items(payload['exception_ids']) + action = payload['action'] + params = payload.get('params') or {} + + self._validate_batch(items, action) + + preview_items = [] + planned_operations = [] + warnings = [] + risk_level = 'low' + + for item in items: + item_preview = self._preview_item(item, action, params, config_snapshot) + item_operations = item_preview['planned_operations'] + item_warnings = item_preview['warnings'] + item_risk = item_preview['risk_level'] + preview_item = { + 'exception_id': item['exception_id'], + 'filename': item['filename'], + 'exception_type': item['exception_type'], + 'planned_operations': item_operations, + 'warnings': item_warnings, + } + if item_preview.get('final_library_preview') is not None: + preview_item['final_library_preview'] = item_preview['final_library_preview'] + preview_items.append(preview_item) + planned_operations.extend(item_operations) + warnings.extend(item_warnings) + risk_level = self._merge_risk(risk_level, item_risk) + + return { + 'action': action, + 'items': preview_items, + 'requires_confirmation': True, + 'planned_operations': planned_operations, + 'conflict_summary': { + 'item_count': len(preview_items), + 'mixed_types': len({item['exception_type'] for item in preview_items}) > 1, + }, + 'risk_level': risk_level, + 'warnings': warnings, + } + + def _validate_batch(self, items: list[dict], action: str): + if not items: + raise ValueError('至少选择一个异常项') + types = {item['exception_type'] for item in items} + if len(types) > 1: + raise ValueError('批量动作不支持混合异常类型') + for item in items: + if action not in (item.get('available_actions') or []): + raise ValueError(f'异常项 {item["exception_id"]} 不支持动作 {action}') + + def _load_exception_items(self, exception_ids: list[int], require_open: bool = False) -> list[dict]: + ids = list(dict.fromkeys(exception_ids or [])) + items = [self.exception_service.get_item(exception_id) for exception_id in ids] + if require_open: + for item in items: + if item.get('exception_resolution_status') != 'open': + raise ValueError(f'异常项 {item["exception_id"]} 当前不可执行') + return items + + def _preview_item(self, item: dict, action: str, params: dict, config_snapshot: dict) -> dict: + item = self._with_task_item_id(item) + source_item = item + current_path = item.get('current_file_path') + + if action == 'ignore_exception': + return self._item_preview( + [self._op('status_update', current_path, None, '标记为已忽略,不执行物理删除')], + ['真实执行仅做安全忽略或转入 review trash,不会物理删除源文件。'], + 'low', + ) + if action == 'delete_file': + return self._item_preview( + [self._op('trash', current_path, None, '永久删除当前文件')], + ['该动作会真实删除文件,执行后无法恢复。'], + 'high', + ) + if action == 'edit_metadata': + return self._item_preview([self._op('metadata_write', current_path, current_path, '写入元数据标签')], [], 'low') + if action == 'retry_match': + providers = params.get('providers') or [] + description = '重新执行单文件匹配' + if providers: + description = f'重新执行单文件匹配 ({"/".join(providers)})' + return self._item_preview([self._op('status_update', current_path, None, description)], [], 'low') + if action == 'select_match_candidate': + return self._item_preview([self._op('status_update', current_path, None, '确认现有匹配候选')], [], 'low') + if action == 'retry_preprocess': + return self._item_preview([self._op('status_update', current_path, None, '重跑预处理与指纹提取')], [], 'low') + if action == 'move_to_review_trash': + return self._item_preview([self._op('trash', current_path, None, '移动到 review trash')], [], 'medium') + if action == 'keep_existing': + return self._item_preview( + [self._op('trash', current_path, item.get('trash_file_path'), '保留库内文件并移走当前文件')], + [], + 'medium', + ) + if action == 'replace_existing': + return self._item_preview( + [ + self._op('replace', item.get('duplicate_of_path'), None, '将库内旧文件移入 review trash'), + self._op('move', current_path, item.get('duplicate_of_path'), '当前文件覆盖进入库内目标'), + ], + [], + 'high', + ) + if action in {'retry_organize', 'save_and_organize', 'keep_both_with_rename'}: + override = params.get('target_relative_path') if action == 'retry_organize' else None + metadata_patch = params.get('metadata_patch') if action == 'save_and_organize' else None + if action == 'save_and_organize' and params.get('metadata_patch'): + item = { + **item, + 'matched_metadata_json': self.metadata_normalizer.normalize_item(item, metadata_patch), + } + if action == 'save_and_organize' and not can_ingest_metadata(self.metadata_normalizer.normalize_item(item)): + raise ValueError('加入音乐库前必须补齐 title、artist、album_artist') + plan = self.organize_service.plan(item, config_snapshot['output'], override) + final_library_preview = None + if action == 'save_and_organize': + final_library_preview = self._build_final_library_preview( + source_item, metadata_patch, plan, self.metadata_normalizer.normalize_item(item) + ) + return self._item_preview( + [ + self._op('move', current_path, str(Path(config_snapshot['output']) / plan['planned_relative_path']), '移动到目标库路径'), + self._op('status_update', current_path, None, f'更新入库路径 {plan["planned_relative_path"]}'), + ], + [], + 'medium', + final_library_preview=final_library_preview, + ) + raise ValueError(f'Unsupported action: {action}') + + def _item_preview( + self, + planned_operations: list[dict], + warnings: list[str], + risk_level: str, + *, + final_library_preview: dict | None = None, + ) -> dict: + return { + 'planned_operations': planned_operations, + 'warnings': warnings, + 'risk_level': risk_level, + 'final_library_preview': final_library_preview, + } + + def _build_final_library_preview( + self, item: dict, metadata_patch: dict | None, plan: dict, final_metadata: dict + ) -> dict: + return { + 'metadata': final_metadata, + 'metadata_sources': self._build_metadata_sources(item, metadata_patch, final_metadata), + 'target_relative_path': plan['planned_relative_path'], + 'target_file_path': str(Path(plan['output_root']) / plan['planned_relative_path']), + } + + def _merge_risk(self, current: str, next_risk: str) -> str: + order = {'low': 0, 'medium': 1, 'high': 2} + return next_risk if order[next_risk] > order[current] else current + + def _op(self, op_type: str, source_path: str | None, target_path: str | None, description: str) -> dict: + return { + 'type': op_type, + 'source_path': source_path, + 'target_path': target_path, + 'description': description, + } + + def _with_task_item_id(self, item: dict) -> dict: + if 'id' in item: + return item + if 'exception_id' not in item: + return item + return {**item, 'id': item['exception_id']} + + def _build_metadata_sources(self, item: dict, metadata_patch: dict | None, final_metadata: dict) -> dict: + raw_metadata = item.get('original_tags_json') or {} + matched_metadata = item.get('matched_metadata_json') or {} + patch = metadata_patch or {} + sources = {} + + for field in METADATA_PREVIEW_FIELDS: + if field not in final_metadata: + continue + if field in patch and patch[field] is not None: + sources[field] = '手动编辑' + elif self._has_metadata_value(matched_metadata, field): + sources[field] = self._match_source_label(item) + elif self._has_metadata_value(raw_metadata, field): + sources[field] = '原始标签' + else: + sources[field] = '归一化推导' + + for field in ('normalization_strategy', 'album_artist_reason', 'compilation', 'artist_tokens', 'display_artist'): + if field in final_metadata: + sources[field] = '归一化推导' + + return sources + + def _has_metadata_value(self, metadata: dict, field: str) -> bool: + return field in metadata and metadata.get(field) not in (None, '') + + def _match_source_label(self, item: dict) -> str: + source = str(item.get('match_source') or '').strip() + labels = { + 'musicbrainz': 'MusicBrainz', + 'acoustid': 'AcoustID', + 'netease': '网易云', + 'qq': 'QQ 音乐', + 'spotify': 'Spotify', + } + return labels.get(source.lower(), source or '匹配结果') diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..54ce0cd --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,41 @@ + \ No newline at end of file diff --git a/backend/tests/test_repair_preview.py b/backend/tests/test_repair_preview.py new file mode 100644 index 0000000..437496e --- /dev/null +++ b/backend/tests/test_repair_preview.py @@ -0,0 +1,159 @@ +import os +import tempfile +import unittest +from pathlib import Path + +os.environ['MUSIC_WORKSHOP_DB_PATH'] = str( + Path(tempfile.gettempdir()) / f'music_workshop_repair_preview_{next(tempfile._get_candidate_names())}.db' +) + +from backend.app.exception_service import ExceptionService +from backend.app.repair_runner import RepairService +from backend.app.task_store import TaskStore + + +class RepairPreviewTests(unittest.TestCase): + def setUp(self): + self.db_path = Path(os.environ['MUSIC_WORKSHOP_DB_PATH']) + if self.db_path.exists(): + self.db_path.unlink() + self.output_root = Path(tempfile.gettempdir()) / f'music-workshop-output-{next(tempfile._get_candidate_names())}' + self.task_store = TaskStore(self.db_path) + self.exception_service = ExceptionService(self.task_store) + self.repair_service = RepairService( + self.task_store, + self.exception_service, + matcher=None, + preprocessor=None, + task_stream=None + ) + self.task = self.task_store.create_task_if_idle( + { + 'input': '/tmp/input', + 'output': str(self.output_root), + 'trash': '/tmp/trash' + } + ) + + def test_save_and_organize_preview_returns_final_library_preview(self): + item = self._insert_metadata_exception( + original_tags_json={ + 'title': 'Raw Title', + 'artist': 'Raw Artist', + 'album': 'Raw Album', + 'year': 1999 + }, + matched_metadata_json={ + 'title': 'Matched Title', + 'artist': 'Matched Artist', + 'album': 'Matched Album', + 'album_artist': 'Matched Artist', + 'track_number': 7 + }, + match_source='musicbrainz' + ) + + preview = self.repair_service.preview( + { + 'exception_ids': [item['id']], + 'action': 'save_and_organize', + 'params': {'metadata_patch': {'title': 'Manual Title'}} + }, + {'output': str(self.output_root), 'trash': '/tmp/trash'} + ) + + item_preview = preview['items'][0]['final_library_preview'] + self.assertEqual(item_preview['metadata']['title'], 'Manual Title') + self.assertEqual(item_preview['metadata']['artist'], 'Matched Artist') + self.assertEqual(item_preview['metadata']['album_artist'], 'Matched Artist') + self.assertEqual(item_preview['metadata']['year'], 1999) + self.assertEqual(item_preview['metadata_sources']['title'], '手动编辑') + self.assertEqual(item_preview['metadata_sources']['artist'], 'MusicBrainz') + self.assertEqual(item_preview['metadata_sources']['year'], '原始标签') + self.assertEqual(item_preview['target_relative_path'], 'M/Matched Artist/Matched Album/07 - Manual Title.flac') + self.assertEqual( + item_preview['target_file_path'], + str(self.output_root / 'M/Matched Artist/Matched Album/07 - Manual Title.flac') + ) + + def test_save_and_organize_preview_rejects_missing_required_metadata(self): + item = self._insert_metadata_exception( + original_tags_json={'title': 'Raw Title'}, + matched_metadata_json={'title': 'Matched Title'}, + match_source='netease' + ) + + with self.assertRaisesRegex(ValueError, 'title、artist、album_artist'): + self.repair_service.preview( + { + 'exception_ids': [item['id']], + 'action': 'save_and_organize', + 'params': {'metadata_patch': {}} + }, + {'output': str(self.output_root), 'trash': '/tmp/trash'} + ) + + def test_save_and_organize_preview_manual_patch_overrides_candidate_and_raw(self): + item = self._insert_metadata_exception( + original_tags_json={ + 'title': 'Raw Title', + 'artist': 'Raw Artist', + 'album_artist': 'Raw Album Artist' + }, + matched_metadata_json={ + 'title': 'Candidate Title', + 'artist': 'Candidate Artist', + 'album_artist': 'Candidate Album Artist', + 'album': 'Candidate Album' + }, + match_source='netease' + ) + + preview = self.repair_service.preview( + { + 'exception_ids': [item['id']], + 'action': 'save_and_organize', + 'params': { + 'metadata_patch': { + 'artist': 'Manual Artist', + 'album_artist': 'Manual Album Artist' + } + } + }, + {'output': str(self.output_root), 'trash': '/tmp/trash'} + ) + + item_preview = preview['items'][0]['final_library_preview'] + self.assertEqual(item_preview['metadata']['title'], 'Candidate Title') + self.assertEqual(item_preview['metadata']['artist'], 'Manual Artist') + self.assertEqual(item_preview['metadata']['album_artist'], 'Manual Album Artist') + self.assertEqual(item_preview['metadata_sources']['title'], '网易云') + self.assertEqual(item_preview['metadata_sources']['artist'], '手动编辑') + self.assertEqual(item_preview['metadata_sources']['album_artist'], '手动编辑') + + def _insert_metadata_exception(self, **overrides): + filename = overrides.pop('filename', f'item-{next(tempfile._get_candidate_names())}.flac') + extension = Path(filename).suffix or '.flac' + return self.task_store.insert_task_item( + self.task['task_id'], + original_path=f'/tmp/input/{filename}', + current_file_path=f'/tmp/input/{filename}', + relative_path=f'Artist/Album/{filename}', + filename=filename, + extension=extension, + size_bytes=123456, + modified_at='2024-01-01T00:00:00Z', + local_cover=None, + local_lyric=None, + scan_status='queued', + scan_reason=None, + scan_message=None, + match_status='low_score', + match_reason='score_gap_too_small', + match_message='匹配候选分数过低', + **overrides + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/superpowers/plans/2026-05-07-exception-center-redesign.md b/docs/superpowers/plans/2026-05-07-exception-center-redesign.md new file mode 100644 index 0000000..a8ac568 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-exception-center-redesign.md @@ -0,0 +1,2779 @@ +# 异常中心页面优化 — 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 ExceptionPage.jsx 从 800+ 行单体组件重构为 10 个聚焦组件 + 5 个自定义 hooks,并应用深色专业主题样式。 + +**Architecture:** 按 Phase 分层推进 — 先提取纯逻辑层 hooks(不改变 UI),再拆分组件,最后应用样式和动画。数据通过 props 向下流动,回调向上通知,不使用 Context。共享工具函数和常量提取到 `utils/exceptions.js`。 + +**Tech Stack:** React 18 + Vite + Tailwind CSS + lucide-react icons,无后端改动 + +**Spec:** [2026-05-07-exception-center-redesign.md](../specs/2026-05-07-exception-center-redesign.md) + +--- + +## 文件结构总览 + +``` +frontend/src/ +├── pages/ +│ └── ExceptionPage.jsx # Modify: 精简为容器组件 (~100行) +├── components/exceptions/ # Create directory +│ ├── ExceptionStatsBar.jsx # Create +│ ├── ExceptionTypeNav.jsx # Create +│ ├── ExceptionWizard.jsx # Create +│ ├── ExceptionListView.jsx # Create (原高级视图内容) +│ ├── ExceptionListTable.jsx # Create (表格部分) +│ ├── steps/ +│ │ ├── StepSelect.jsx # Create +│ │ ├── StepListen.jsx # Create +│ │ ├── StepMatch.jsx # Create +│ │ ├── StepEdit.jsx # Create +│ │ └── StepConfirm.jsx # Create +│ ├── RepairTaskPanel.jsx # Create +│ └── ActionPreviewModal.jsx # Create +├── hooks/ +│ ├── useExceptionSummary.js # Create +│ ├── useExceptionList.js # Create +│ ├── useExceptionDetail.js # Create +│ ├── useRepairTask.js # Create +│ └── useWizardState.js # Create +└── utils/ + └── exceptions.js # Create (共享常量和工具函数) +``` + +--- + +## Phase 1: 共享工具层 + +### Task 1.1: 提取共享常量和工具函数到 utils/exceptions.js + +**Files:** +- Create: `frontend/src/utils/exceptions.js` +- Modify: `frontend/src/pages/ExceptionPage.jsx` (import from utils instead of inline) + +- [ ] **Step 1: 创建 utils/exceptions.js 包含所有共享常量** + +```javascript +// frontend/src/utils/exceptions.js + +export const EXCEPTION_FILTERS = [ + { id: 'all', name: '全部异常' }, + { id: 'missing_tags', name: '元数据缺失' }, + { id: 'duplicates', name: '文件重复' }, + { id: 'match_failed', name: '匹配失败' }, + { id: 'low_score', name: '匹配分过低' }, + { id: 'convert_failed', name: '转码失败' }, + { id: 'organize_failed', name: '入库失败' } +]; + +export const RESOLUTION_FILTERS = [ + { id: 'open', name: '开放中' }, + { id: 'resolved', name: '已解决' }, + { id: 'ignored', name: '已忽略' }, + { id: 'all', name: '全部' } +]; + +export const ACTION_LABELS = { + retry_match: '一键匹配', + select_match_candidate: '确认候选', + edit_metadata: '保存草稿', + save_and_organize: '加入音乐库', + keep_existing: '忽略并删除新版', + replace_existing: '覆盖替换旧版', + keep_both_with_rename: '重命名新版并保留双版本', + retry_preprocess: '重跑预处理', + move_to_review_trash: '移入 Review Trash', + retry_organize: '重试入库', + edit_target_path: '编辑目标路径', + ignore_exception: '永久忽略', + delete_file: '删除文件' +}; + +export const PROVIDER_MODES = [ + { id: 'all', label: '多源并行', providers: [] }, + { id: 'authoritative', label: '权威优先', providers: ['acoustid', 'musicbrainz'] }, + { id: 'netease', label: '网易云', providers: ['netease'] }, + { id: 'qq', label: 'QQ 音乐', providers: ['qq'] }, + { id: 'spotify', label: 'Spotify', providers: ['spotify'] } +]; + +export const BULK_ACTIONS = { + match_failed: ['retry_match', 'ignore_exception'], + low_score: ['retry_match', 'ignore_exception'], + organize_failed: ['ignore_exception', 'retry_organize'], + missing_tags: ['retry_match', 'ignore_exception'], + duplicates: ['ignore_exception'], + convert_failed: ['ignore_exception'] +}; + +export const ITEMS_PER_PAGE = 8; +export const METADATA_QUEUE_TYPES = ['missing_tags', 'match_failed', 'low_score']; +export const METADATA_QUEUE_PAGE_SIZE = 100; +export const METADATA_FIELDS = ['title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics']; +export const REQUIRED_FIELDS = ['title', 'artist', 'album_artist']; + +export const WIZARD_STEPS = [ + { id: 'select', label: '选择歌曲' }, + { id: 'listen', label: '试听确认' }, + { id: 'match', label: '推荐匹配' }, + { id: 'edit', label: '手动编辑' }, + { id: 'confirm', label: '入库确认' } +]; + +// --- Utility functions --- + +export function compareTimestampDesc(a, b) { + return new Date(b || 0).getTime() - new Date(a || 0).getTime(); +} + +export function formatTimestamp(value) { + if (!value) return '--'; + try { + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' + }).format(new Date(value)); + } catch { return String(value); } +} + +export function formatSeconds(value) { + if (!Number.isFinite(value) || value <= 0) return '--:--'; + const total = Math.floor(value); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +export function formatConfidence(value) { + if (value == null) return '--'; + return `${Number(value).toFixed(1)} 分`; +} + +export function formatMetadataValue(value) { + if (value === null || value === undefined || value === '') return '--'; + return String(value); +} + +export function providerLabel(provider) { + const labels = { acoustid: 'AcoustID', musicbrainz: 'MusicBrainz', netease: '网易云', qq: 'QQ 音乐', spotify: 'Spotify' }; + const key = String(provider || '').toLowerCase(); + return labels[key] || provider || '推荐候选'; +} + +export function isTerminalRepairStatus(status) { + return status === 'completed' || status === 'failed'; +} + +export function normalizeActionParams(action, params) { + if (action === 'retry_match') { + return { provider_mode: params.provider_mode || 'all', providers: params.providers || [] }; + } + if (action === 'save_and_organize' || action === 'edit_metadata') { + return { metadata_patch: { ...(params.metadata_patch || {}) } }; + } + return params; +} + +export function buildDefaultParams(action, detailRecord) { + if (action === 'retry_match') { + return { provider_mode: 'all', providers: [] }; + } + if (action === 'select_match_candidate') { + return { candidate_index: 0 }; + } + if (action === 'edit_metadata' || action === 'save_and_organize') { + const metadata = + detailRecord?.effective_metadata || + detailRecord?.matched_metadata_json || + detailRecord?.original_tags_json || + {}; + return { + metadata_patch: { + title: metadata.title || '', + artist: metadata.artist || '', + album: metadata.album || '', + album_artist: metadata.album_artist || '', + track_number: metadata.track_number ?? null, + disc_number: metadata.disc_number ?? null, + year: metadata.year ?? null, + lyrics: metadata.lyrics || '' + } + }; + } + if (action === 'retry_organize' || action === 'edit_target_path') { + return { target_relative_path: detailRecord?.library_relative_path || '' }; + } + return {}; +} + +export function isMetadataWorkflowException(exceptionType) { + return ['missing_tags', 'match_failed', 'low_score'].includes(exceptionType); +} + +export function inferDefaultAction(detailRecord) { + if (!detailRecord) return ''; + const actions = detailRecord.available_actions || []; + if (actions.includes('save_and_organize')) return 'save_and_organize'; + if (actions.includes('retry_match')) return 'retry_match'; + if (actions.includes('retry_organize')) return 'retry_organize'; + return actions[0] || ''; +} + +export function getMissingRequiredFields(metadata) { + return REQUIRED_FIELDS + .filter((field) => !String(metadata?.[field] || '').trim()) + .map((field) => { + const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' }; + return labels[field] || field; + }); +} + +// --- Styling helpers --- + +export function chipClass(active, compact = false) { + return `rounded-full border px-3 py-1.5 text-xs transition ${ + compact ? '' : '' + } ${ + active + ? 'border-indigo-400/50 bg-indigo-500/15 text-indigo-100' + : 'border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-500' + }`; +} + +export function inputClass() { + return 'w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none transition placeholder:text-slate-500 focus:border-indigo-400/60'; +} + +export function actionButtonClass(enabled = true) { + return `inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-xs font-medium transition ${ + enabled + ? 'border border-indigo-400/40 bg-indigo-500/15 text-indigo-100 hover:bg-indigo-500/20' + : 'cursor-not-allowed border border-slate-800 bg-slate-900 text-slate-600' + }`; +} + +export function riskClass(riskLevel) { + return { + low: 'bg-emerald-500/10 text-emerald-200', + medium: 'bg-amber-500/10 text-amber-200', + high: 'bg-rose-500/10 text-rose-200' + }[riskLevel || 'low']; +} + +export function getFilterCount(summary, filterId) { + if (!summary) return '-'; + if (filterId === 'all') return summary.total ?? '-'; + return summary.counts_by_type?.[filterId] ?? '-'; +} + +export function getMetadataQueueCounts(queue) { + const counts = { missing_tags: 0, match_failed: 0, low_score: 0 }; + queue.forEach((item) => { + if (counts[item.exception_type] !== undefined) { + counts[item.exception_type] += 1; + } + }); + return counts; +} + +export function shouldRefreshExceptionListFully(repairTask) { + const plan = repairTask?.repair_plan_json || {}; + return (plan.total_exceptions ?? 0) > 1; +} +``` + +- [ ] **Step 2: 更新 ExceptionPage.jsx 的 import 引用常量** + +将 `ExceptionPage.jsx` 中第 35-127 行的常量定义和工具函数,替换为从 `../utils/exceptions` 导入。不删除原文件中的定义(如果还有旧代码引用),先添加导入: + +```javascript +import { + EXCEPTION_FILTERS, RESOLUTION_FILTERS, ACTION_LABELS, PROVIDER_MODES, BULK_ACTIONS, + ITEMS_PER_PAGE, METADATA_QUEUE_TYPES, METADATA_QUEUE_PAGE_SIZE, WIZARD_STEPS, METADATA_FIELDS, + compareTimestampDesc, formatTimestamp, formatSeconds, formatConfidence, isTerminalRepairStatus, + normalizeActionParams, buildDefaultParams, isMetadataWorkflowException, inferDefaultAction, + getFilterCount, getMetadataQueueCounts, shouldRefreshExceptionListFully, + chipClass, actionButtonClass, inputClass, riskClass +} from '../utils/exceptions'; +``` + +原文件第 35-127 行的常量定义和工具函数保持不变(Phase 3 容器重构时才移除)。 + +- [ ] **Step 3: 更新 MissingTagsInlinePanel.jsx 的 import 引用标注** + +`MissingTagsInlinePanel.jsx` 中已有本地定义的这些常量和工具函数,暂不修改(该组件后续可能被新的 wizard 步骤组件替代)。仅验证其可正常渲染。 + +- [ ] **Step 4: 验证构建** + +```bash +cd frontend && npm run build +``` +Expected: PASS (no errors, no warnings for unused imports from utils is OK) + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/utils/exceptions.js frontend/src/pages/ExceptionPage.jsx +git commit -m "feat: extract shared constants and utilities to utils/exceptions.js + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Phase 2: 自定义 Hooks + +### Task 2.1: 创建 useExceptionSummary hook + +**Files:** +- Create: `frontend/src/hooks/useExceptionSummary.js` + +- [ ] **Step 1: 编写 hook** + +```javascript +// frontend/src/hooks/useExceptionSummary.js +import { useState, useEffect } from 'react'; +import { fetchExceptionSummary } from '../api/exceptions'; + +export default function useExceptionSummary() { + const [summary, setSummary] = useState(null); + const [error, setError] = useState(''); + + const refresh = () => { + fetchExceptionSummary() + .then(setSummary) + .catch((err) => setError(err.message || '异常概览加载失败')); + }; + + useEffect(() => { + const controller = new AbortController(); + fetchExceptionSummary({ signal: controller.signal }) + .then(setSummary) + .catch((err) => { + if (err.name !== 'AbortError') { + setError(err.message || '异常概览加载失败'); + } + }); + return () => controller.abort(); + }, []); + + return { summary, error, refresh }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/hooks/useExceptionSummary.js +git commit -m "feat: add useExceptionSummary hook + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 2.2: 创建 useExceptionList hook + +**Files:** +- Create: `frontend/src/hooks/useExceptionList.js` + +- [ ] **Step 1: 编写 hook** + +```javascript +// frontend/src/hooks/useExceptionList.js +import { useState, useEffect, useCallback } from 'react'; +import { fetchExceptionItems } from '../api/exceptions'; +import { METADATA_QUEUE_TYPES, METADATA_QUEUE_PAGE_SIZE, ITEMS_PER_PAGE, compareTimestampDesc } from '../utils/exceptions'; + +export default function useExceptionList({ viewMode, activeFilter, resolutionFilter, currentPage }) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [metadataQueue, setMetadataQueue] = useState([]); + const [metadataTotal, setMetadataTotal] = useState(0); + const [isListLoading, setIsListLoading] = useState(true); + const [isMetadataQueueLoading, setIsMetadataQueueLoading] = useState(true); + const [listError, setListError] = useState(''); + const [metadataQueueError, setMetadataQueueError] = useState(''); + + const refreshList = useCallback(() => { + if (viewMode !== 'advanced') return; + setIsListLoading(true); + setListError(''); + fetchExceptionItems({ + type: activeFilter, + resolutionStatus: resolutionFilter, + page: currentPage, + pageSize: ITEMS_PER_PAGE + }) + .then((payload) => { + setItems(payload.items); + setTotal(payload.total); + }) + .catch((err) => { + setItems([]); + setTotal(0); + setListError(err.message || '异常列表加载失败'); + }) + .finally(() => setIsListLoading(false)); + }, [viewMode, activeFilter, resolutionFilter, currentPage]); + + const refreshMetadataQueue = useCallback(() => { + setIsMetadataQueueLoading(true); + setMetadataQueueError(''); + Promise.all( + METADATA_QUEUE_TYPES.map((type) => + fetchExceptionItems({ type, resolutionStatus: 'open', page: 1, pageSize: METADATA_QUEUE_PAGE_SIZE }) + ) + ) + .then((payloads) => { + const queue = payloads + .flatMap((p) => p.items) + .sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at)); + setMetadataQueue(queue); + setMetadataTotal(payloads.reduce((sum, p) => sum + p.total, 0)); + }) + .catch((err) => { + setMetadataQueue([]); + setMetadataTotal(0); + setMetadataQueueError(err.message || '元数据异常队列加载失败'); + }) + .finally(() => setIsMetadataQueueLoading(false)); + }, []); + + useEffect(() => { + const controller = new AbortController(); + if (viewMode !== 'advanced') { + setIsListLoading(false); + return undefined; + } + setIsListLoading(true); + setListError(''); + fetchExceptionItems( + { type: activeFilter, resolutionStatus: resolutionFilter, page: currentPage, pageSize: ITEMS_PER_PAGE }, + { signal: controller.signal } + ) + .then((payload) => { + setItems(payload.items); + setTotal(payload.total); + }) + .catch((err) => { + if (err.name !== 'AbortError') { setItems([]); setTotal(0); setListError(err.message || '加载失败'); } + }) + .finally(() => { if (!controller.signal.aborted) setIsListLoading(false); }); + return () => controller.abort(); + }, [activeFilter, resolutionFilter, currentPage, viewMode]); + + useEffect(() => { + const controller = new AbortController(); + setIsMetadataQueueLoading(true); + setMetadataQueueError(''); + Promise.all( + METADATA_QUEUE_TYPES.map((type) => + fetchExceptionItems( + { type, resolutionStatus: 'open', page: 1, pageSize: METADATA_QUEUE_PAGE_SIZE }, + { signal: controller.signal } + ) + ) + ) + .then((payloads) => { + const queue = payloads.flatMap((p) => p.items).sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at)); + setMetadataQueue(queue); + setMetadataTotal(payloads.reduce((sum, p) => sum + p.total, 0)); + }) + .catch((err) => { + if (err.name !== 'AbortError') { setMetadataQueue([]); setMetadataTotal(0); setMetadataQueueError(err.message || '加载失败'); } + }) + .finally(() => { if (!controller.signal.aborted) setIsMetadataQueueLoading(false); }); + return () => controller.abort(); + }, [viewMode]); + + // Refresh when viewMode switches to wizard + useEffect(() => { + if (viewMode === 'wizard') refreshMetadataQueue(); + }, [viewMode, refreshMetadataQueue]); + + return { + items, total, metadataQueue, metadataTotal, + isListLoading, isMetadataQueueLoading, + listError, metadataQueueError, + setItems, setMetadataQueue, + refreshList, refreshMetadataQueue + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/hooks/useExceptionList.js +git commit -m "feat: add useExceptionList hook + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 2.3: 创建 useExceptionDetail hook + +**Files:** +- Create: `frontend/src/hooks/useExceptionDetail.js` + +- [ ] **Step 1: 编写 hook** + +```javascript +// frontend/src/hooks/useExceptionDetail.js +import { useState, useEffect, useCallback } from 'react'; +import { fetchExceptionItem } from '../api/exceptions'; + +export default function useExceptionDetail(exceptionId) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const refresh = useCallback(() => { + if (!exceptionId) { setDetail(null); return; } + setLoading(true); + setError(''); + fetchExceptionItem(exceptionId) + .then(setDetail) + .catch((err) => { setDetail(null); setError(err.message || '详情加载失败'); }) + .finally(() => setLoading(false)); + }, [exceptionId]); + + useEffect(() => { + if (!exceptionId) { setDetail(null); return; } + const controller = new AbortController(); + setLoading(true); + setError(''); + fetchExceptionItem(exceptionId, { signal: controller.signal }) + .then(setDetail) + .catch((err) => { + if (err.name !== 'AbortError') { setDetail(null); setError(err.message || '详情加载失败'); } + }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [exceptionId]); + + return { detail, loading, error, refresh }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/hooks/useExceptionDetail.js +git commit -m "feat: add useExceptionDetail hook + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 2.4: 创建 useRepairTask hook + +**Files:** +- Create: `frontend/src/hooks/useRepairTask.js` + +- [ ] **Step 1: 编写 hook** + +```javascript +// frontend/src/hooks/useRepairTask.js +import { useState, useEffect, useRef, useCallback } from 'react'; +import { createRepairTaskStream, fetchCurrentRepairTask, fetchRepairTask, fetchRepairTaskLogs } from '../api/repairs'; +import { isTerminalRepairStatus } from '../utils/exceptions'; + +export default function useRepairTask() { + const [repairTask, setRepairTask] = useState(null); + const [repairLogs, setRepairLogs] = useState([]); + const [executionStateByExceptionId, setExecutionStateByExceptionId] = useState({}); + const completedRefreshRef = useRef(new Set()); + + // Load current repair task on mount + useEffect(() => { + fetchCurrentRepairTask() + .then((payload) => { + if (!payload.task) return; + setRepairTask(payload.task); + return fetchRepairTaskLogs(payload.task.task_id, 1, 20).then((lp) => setRepairLogs(lp.logs)); + }) + .catch(() => {}); + }, []); + + // WebSocket for repair task updates + useEffect(() => { + if (!repairTask?.task_id) return; + const socket = createRepairTaskStream(repairTask.task_id); + socket.onmessage = async (event) => { + const payload = JSON.parse(event.data); + if (payload.type === 'task.snapshot') { + setRepairTask(payload.data.task); + setRepairLogs(payload.data.recent_logs || []); + return; + } + try { + const tp = await fetchRepairTask(repairTask.task_id); + const lp = await fetchRepairTaskLogs(repairTask.task_id, 1, 20); + setRepairTask(tp.task); + setRepairLogs(lp.logs); + } catch (err) { console.error('Repair task refresh error', err); } + }; + return () => socket.close(); + }, [repairTask?.task_id]); + + // Track execution state per exception + useEffect(() => { + if (!repairTask?.task_id) return; + setExecutionStateByExceptionId((prev) => { + let changed = false; + const next = { ...prev }; + Object.entries(prev).forEach(([eid, state]) => { + if (!state || state.repairTaskId !== repairTask.task_id) return; + const nextStatus = + repairTask.status === 'completed' ? 'completed' + : repairTask.status === 'failed' ? 'failed' + : repairTask.status === 'running' ? 'running' + : 'accepted'; + const nextError = repairTask.status === 'failed' ? repairTask.error_message || '执行失败' : ''; + if (state.status !== nextStatus || state.error !== nextError) { + next[eid] = { ...state, status: nextStatus, error: nextError }; + changed = true; + } + }); + return changed ? next : prev; + }); + }, [repairTask]); + + const registerExecution = useCallback((exceptionId, action, repairTaskId, previewPayload) => { + setExecutionStateByExceptionId((prev) => ({ + ...prev, + [exceptionId]: { + exceptionId, action, status: 'accepted', repairTaskId, + submittedAt: new Date().toISOString(), error: '', previewPayload + } + })); + }, []); + + const setExecuting = useCallback((exceptionId, action, previewPayload) => { + setExecutionStateByExceptionId((prev) => ({ + ...prev, + [exceptionId]: { + exceptionId, action, status: 'submitting', repairTaskId: null, + submittedAt: new Date().toISOString(), error: '', + previewPayload: previewPayload || prev[exceptionId]?.previewPayload + } + })); + }, []); + + const setExecutionFailed = useCallback((exceptionId, action, errorMessage, previewPayload) => { + setExecutionStateByExceptionId((prev) => ({ + ...prev, + [exceptionId]: { + exceptionId, action, status: 'failed', error: errorMessage, + previewPayload: previewPayload || prev[exceptionId]?.previewPayload + } + })); + }, []); + + const isTerminal = isTerminalRepairStatus(repairTask?.status); + const taskCompleted = isTerminal && completedRefreshRef; + + return { + repairTask, repairLogs, + executionStateByExceptionId, + registerExecution, setExecuting, setExecutionFailed, + completedRefreshRef, taskCompleted, + setRepairTask, setRepairLogs + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/hooks/useRepairTask.js +git commit -m "feat: add useRepairTask hook for WebSocket repair task tracking + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 2.5: 创建 useWizardState hook + +**Files:** +- Create: `frontend/src/hooks/useWizardState.js` + +- [ ] **Step 1: 编写 hook** + +```javascript +// frontend/src/hooks/useWizardState.js +import { useState, useCallback } from 'react'; +import { buildDefaultParams, inferDefaultAction, normalizeActionParams } from '../utils/exceptions'; + +export default function useWizardState(initialStep = 'select') { + const [wizardStep, setWizardStep] = useState(initialStep); + const [selectedAction, setSelectedAction] = useState(''); + const [actionParams, setActionParams] = useState({}); + const [previewState, setPreviewState] = useState({ loading: false, payload: null, error: '', action: '' }); + const [executeError, setExecuteError] = useState(''); + + const initForDetail = useCallback((detailRecord, viewMode) => { + if (!detailRecord) { + setSelectedAction(''); + setActionParams({}); + setPreviewState({ loading: false, payload: null, error: '', action: '' }); + setExecuteError(''); + return; + } + const nextAction = + viewMode === 'wizard' && ['missing_tags', 'match_failed', 'low_score'].includes(detailRecord.exception_type) + ? 'save_and_organize' + : inferDefaultAction(detailRecord); + setSelectedAction(nextAction); + setActionParams(buildDefaultParams(nextAction, detailRecord)); + setPreviewState({ loading: false, payload: null, error: '', action: '' }); + setExecuteError(''); + }, []); + + const focusAction = useCallback((action) => { + setSelectedAction(action); + setPreviewState({ loading: false, payload: null, error: '', action: '' }); + setExecuteError(''); + }, []); + + const resetPreview = useCallback(() => { + setPreviewState({ loading: false, payload: null, error: '', action: '' }); + }, []); + + return { + wizardStep, setWizardStep, + selectedAction, setSelectedAction, + actionParams, setActionParams, + previewState, setPreviewState, + executeError, setExecuteError, + initForDetail, focusAction, resetPreview + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/hooks/useWizardState.js +git commit -m "feat: add useWizardState hook + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Phase 3: 拆分视觉组件 + +### Task 3.1: 创建 ExceptionStatsBar 组件 + +**Files:** +- Create: `frontend/src/components/exceptions/ExceptionStatsBar.jsx` + +- [ ] **Step 1: 编写组件** + +```javascript +// frontend/src/components/exceptions/ExceptionStatsBar.jsx +export default function ExceptionStatsBar({ summary, metadataTotal, metadataQueueCounts, viewMode }) { + if (viewMode === 'wizard') { + return ( +
+ + + + +
+ ); + } + + return ( +
+ + + + +
+ ); +} + +function MetricCard({ label, value, tone }) { + const colors = { + indigo: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', + amber: 'border-amber-500/30 bg-amber-500/10 text-amber-200', + rose: 'border-rose-500/30 bg-rose-500/10 text-rose-200', + emerald: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200', + cyan: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200' + }; + return ( +
+
{value}
+
{label}
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/exceptions/ExceptionStatsBar.jsx +git commit -m "feat: add ExceptionStatsBar component + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 3.2: 创建 ExceptionTypeNav 组件 + +**Files:** +- Create: `frontend/src/components/exceptions/ExceptionTypeNav.jsx` + +- [ ] **Step 1: 编写组件** + +```javascript +// frontend/src/components/exceptions/ExceptionTypeNav.jsx +import { EXCEPTION_FILTERS, RESOLUTION_FILTERS, chipClass, getFilterCount } from '../../utils/exceptions'; + +export default function ExceptionTypeNav({ + activeFilter, onFilterChange, + resolutionFilter, onResolutionChange, + summary +}) { + return ( +
+
+ {EXCEPTION_FILTERS.map((filter) => ( + + ))} +
+
+ 处理状态 + {RESOLUTION_FILTERS.map((filter) => ( + + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/exceptions/ExceptionTypeNav.jsx +git commit -m "feat: add ExceptionTypeNav component + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 3.3: 创建 RepairTaskPanel 组件 + +**Files:** +- Create: `frontend/src/components/exceptions/RepairTaskPanel.jsx` + +- [ ] **Step 1: 编写组件** + +```javascript +// frontend/src/components/exceptions/RepairTaskPanel.jsx +import { LoaderCircle } from 'lucide-react'; +import { isTerminalRepairStatus } from '../../utils/exceptions'; + +export default function RepairTaskPanel({ repairTask, repairLogs, executionState }) { + if (!repairTask && !executionState) return null; + + const statusLabel = { + submitting: '正在提交...', accepted: '已提交', running: '执行中', + completed: '已完成', failed: '失败' + }; + + const statusColor = { + submitting: 'border-amber-500/30 bg-amber-500/10 text-amber-200', + accepted: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', + running: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', + completed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200', + failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200' + }; + + return ( +
+
+ + ⚡ 修复任务 + {repairTask && ( + + {statusLabel[repairTask.status] || repairTask.status} + + )} +
+ {repairTask && ( +
+ 任务号: {repairTask.task_id} + {repairTask.error_message && ( + {repairTask.error_message} + )} +
+ )} + {repairLogs.length > 0 && ( +
+ {repairLogs.map((log, i) => ( +
{log.message || log.stage || '--'}
+ ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/exceptions/RepairTaskPanel.jsx +git commit -m "feat: add RepairTaskPanel component + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 3.4: 创建 ActionPreviewModal 组件 + +**Files:** +- Create: `frontend/src/components/exceptions/ActionPreviewModal.jsx` + +- [ ] **Step 1: 编写组件** + +```javascript +// frontend/src/components/exceptions/ActionPreviewModal.jsx +import { ShieldAlert, X } from 'lucide-react'; +import { riskClass } from '../../utils/exceptions'; + +export default function ActionPreviewModal({ previewState, onClose, onConfirm, onCancel }) { + if (!previewState || !previewState.payload) return null; + const { payload, loading, error, action } = previewState; + + if (loading) { + return ( +
+
+ +

正在生成预览...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+

+ + 操作预览 +

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

没有待处理的异常

+
+ ); + } + + return ( +
+ {metadataQueue.map((item) => { + const selected = selectedExceptionId === item.exception_id; + const ap = item.audio_props_json || {}; + return ( + + ); + })} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/exceptions/steps/StepSelect.jsx +git commit -m "feat: add StepSelect wizard component + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 4.2: 创建 StepListen 组件 + +**Files:** +- Create: `frontend/src/components/exceptions/steps/StepListen.jsx` + +- [ ] **Step 1: 编写组件** + +```javascript +// frontend/src/components/exceptions/steps/StepListen.jsx +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Headphones, Play, Pause, LoaderCircle } from 'lucide-react'; +import { buildExceptionAudioUrl } from '../../../api/exceptions'; +import { formatSeconds } from '../../../utils/exceptions'; + +export default function StepListen({ detailRecord, isLoading }) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [audioError, setAudioError] = useState(''); + + if (!detailRecord && !isLoading) { + return ( +
+

请先选择一首歌曲

+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + const audioUrl = buildExceptionAudioUrl(detailRecord.exception_id); + const ap = detailRecord.audio_props_json || {}; + + const togglePlay = () => { + if (!audioRef.current) return; + if (audioRef.current.paused) { + audioRef.current.play().catch(() => setAudioError('播放器启动失败')); + } else { + audioRef.current.pause(); + } + }; + + return ( +
+ {/* File info */} +
+

{detailRecord.display_title || '-'}

+

{detailRecord.filename}

+
+ + + + + + +
+
+ + {/* Audio player */} +
+
+
+

试听预览

+

+ 在线试听 +

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

请先选择一首歌曲

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

{detailRecord.display_title || '-'}

+

{detailRecord.filename}

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

现有匹配候选

+
+ {candidates.map((candidate, i) => ( +
+
+
+
+ {candidate.title || 'Unknown'} +
+
+ {[candidate.artist, candidate.album].filter(Boolean).join(' · ')} + {candidate.year ? ` · ${candidate.year}` : ''} +
+
+ {candidate.score != null && ( + = 0.8 ? 'bg-emerald-500/10 text-emerald-200' : + candidate.score >= 0.5 ? 'bg-amber-500/10 text-amber-200' : + 'bg-rose-500/10 text-rose-200' + }`}> + {Math.round(candidate.score * 100)}% + + )} +
+ {candidate.source && ( +
{providerLabel(candidate.source)}
+ )} +
+ ))} +
+
+ )} + + {/* Retry match controls */} +
+

重新匹配

+

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

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

{previewState.error}

+ )} + + {isExecuting && ( +
+ +
+ )} +
+
+ ); +} + +function InfoField({ label, value }) { + return ( +
+
{label}
+
{value || '--'}
+
+ ); +} + +function StatusBadge({ status }) { + const map = { + submitting: 'border-amber-500/30 bg-amber-500/10 text-amber-200', + accepted: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', + running: 'border-indigo-500/30 bg-indigo-500/10 text-indigo-200', + completed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200', + failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200' + }; + const label = { submitting: '提交中', accepted: '已提交', running: '执行中', completed: '已完成', failed: '失败' }[status]; + if (!label) return null; + return {label}; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/components/exceptions/steps/StepMatch.jsx +git commit -m "feat: add StepMatch wizard component + +Co-Authored-By: Claude Opus 4.6 " +``` + +### Task 4.4: 创建 StepEdit 组件 + +**Files:** +- Create: `frontend/src/components/exceptions/steps/StepEdit.jsx` + +- [ ] **Step 1: 编写组件** + +```javascript +// frontend/src/components/exceptions/steps/StepEdit.jsx +import { LoaderCircle, Sparkles } from 'lucide-react'; +import { METADATA_FIELDS, REQUIRED_FIELDS, inputClass, actionButtonClass, formatMetadataValue } from '../../../utils/exceptions'; + +export default function StepEdit({ + detailRecord, isLoading, + metadataPatch, onUpdateMetadata, + canIngest, missingFields, + previewState, onPreview +}) { + if (!detailRecord && !isLoading) { + return ( +
+

请先选择一首歌曲

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

{detailRecord.display_title || '-'}

+

{detailRecord.filename}

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