chore: add project configs, backend repair services, docs, and code quality tooling

- Add pre-commit hooks (ruff, black, prettier) and ESLint/Prettier configs
- Add backend repair services (execution, orchestration, preview) with tests
- Add project documentation (CLAUDE.md, README.md, design specs and plans)
- Add MissingTagsInlinePanel component for exception handling
- Add pyproject.toml with ruff/black configuration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-08 15:49:37 +08:00
parent 7d003ff822
commit be3c086975
17 changed files with 6389 additions and 0 deletions
+28
View File
@@ -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]
+102
View File
@@ -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` 脚本)。
+145
View File
@@ -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)
+248
View File
@@ -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
+110
View File
@@ -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)
+243
View File
@@ -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 '匹配结果')
+41
View File
@@ -0,0 +1,41 @@
<![CDATA=[tool.ruff]
line-length = 100
target-version = "py311"
exclude = [
".git",
"__pycache__",
"build",
"dist",
".venv",
"venv",
"migrations",
]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"C", # flake8-comprehensions
"B", # flake8-bugbear
"UP", # pyupgrade
"RUF", # ruff-specific rules
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # do not perform function calls in argument defaults
"C901", # too complex (handled separately)
]
[tool.ruff.format]
quote-style = "single"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "lf"
[tool.black]
line-length = 100
target-version = ['py311']
]]>
+159
View File
@@ -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()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,250 @@
# 异常中心页面优化 — 设计规格
> 创建日期: 2026-05-07
> 状态: 设计中
## 概述
对 MusicWorkshop 异常中心页面(`ExceptionPage.jsx`)进行全面的视觉、交互、流程和代码结构优化。
### 核心痛点
- 页面不够美观(首要痛点)
- 操作太繁琐
- 向导流程太长(保留 5 步但优化界面)
- 代码维护困难(800+ 行单文件)
## 设计决策
| 维度 | 决策 |
|------|------|
| 视觉风格 | 深色专业(Dark Professional)— 暗色主题 + 靛蓝高亮 + 微弱边框 |
| 页面布局 | 向导聚焦布局(Wizard Focus)— 顶部步骤条 + 左侧队列 + 右侧操作区 |
| 向导步骤 | 保持 5 步(确保入库数据精准性) |
| 代码重构 | 按功能区拆分组件 + 提取自定义 hooks |
| 后端改动 | 无需改动(API 和数据结构不变) |
## 视觉设计
### 配色方案
```
背景: #0f172a (slate-900) — 主背景
卡片: #1e293b (slate-800) — 面板/卡片背景
边框: #334155 (slate-700) — 微弱的组件边框
文字: #e2e8f0 (slate-200) — 主文字
次要: #94a3b8 (slate-400) — 辅助文字/标签
高亮: #6366f1 (indigo-500) — 主要操作/选中状态
成功: #22c55e (green-500) — 完成/入库操作
警告: #f59e0b (amber-500) — 需注意的异常
错误: #ef4444 (red-500) — 失败状态
```
### 异常类型标签色
```
元数据缺失: amber (amber-700 背景, amber-400 文字)
匹配失败: red (red-900 背景, red-400 文字)
匹配分过低: amber
转码失败: red
文件重复: slate
入库失败: red
```
### 排版
- 字体: 继承项目现有 Tailwind 配置(系统字体栈)
- 步骤标题: text-lg font-semibold
- 歌曲名称: text-sm font-medium
- 元数据标签: text-xs text-slate-400
- 按钮: text-sm font-medium rounded-lg
## 页面布局
```
┌──────────────────────────────────────────────────┐
│ 🏠 异常中心 [高级视图] │
├──────────────────────────────────────────────────┤
│ ①选择歌曲 ── ②试听确认 ── ③推荐匹配 ── ④手动编│
│ 辑 ── ⑤入库确认 │
├────────────┬─────────────────────────────────────┤
│ 队列 (12) │ 🎵 01 - 夜曲.mp3 │
│ │ ┌─────────────────────────────────┐│
│ ▸ 01-夜曲 │ │ 匹配候选: ││
│ track_02 │ │ ▸ 夜曲 / 周杰伦 · 十一月的萧邦 ││
│ unknown │ │ 95% · MusicBrainz ││
│ │ │ Nocturne / Jay Chou ││
│ │ │ 78% · Spotify ││
│ │ └─────────────────────────────────┘│
│ │ [确认选择] [手动编辑] [跳过 →] │
├────────────┴─────────────────────────────────────┤
│ ⚡ 修复任务: 3/12 完成 · running │
└──────────────────────────────────────────────────┘
```
## 组件架构
```
src/
├── pages/
│ └── ExceptionPage.jsx # 页面容器(路由入口 + 顶层状态协调)
├── components/exceptions/
│ ├── ExceptionStatsBar.jsx # 顶部统计概览条
│ ├── ExceptionTypeNav.jsx # 异常类型筛选标签
│ ├── ExceptionWizard.jsx # 5步向导主控制器
│ ├── ExceptionListView.jsx # 高级列表视图(备用,对应高级模式)
│ ├── steps/
│ │ ├── StepSelect.jsx # 步骤1:选择歌曲
│ │ ├── StepListen.jsx # 步骤2:试听确认
│ │ ├── StepMatch.jsx # 步骤3:推荐匹配
│ │ ├── StepEdit.jsx # 步骤4:手动编辑
│ │ └── StepConfirm.jsx # 步骤5:入库确认
│ ├── RepairTaskPanel.jsx # 修复任务实时状态面板
│ └── ActionPreviewModal.jsx # 操作预览/确认弹窗
├── hooks/
│ ├── useExceptionSummary.js # 异常概览数据获取
│ ├── useExceptionList.js # 异常列表(分页/筛选/队列)
│ ├── useExceptionDetail.js # 单条异常详情
│ ├── useRepairTask.js # WebSocket 修复任务跟踪
│ └── useWizardState.js # 向导步骤状态管理
```
### 组件职责
**ExceptionPage** — 页面容器,轻量级
- 仅持有顶层路由和视图模式切换(wizard / advanced
- 通过 props 传递共享状态
- 不包含任何步骤逻辑或 UI 细节
**ExceptionStatsBar** — 顶部统计概览
- Props: summary (总数/分类统计)
- 显示:开放中 / 处理中 / 已解决 三个统计卡片
- 深色主题:渐变靛蓝背景卡片
**ExceptionTypeNav** — 类型筛选标签
- Props: activeFilter, onFilterChange, counts
- 水平滚动的胶囊标签,显示各类型数量
- 深色主题:选中靛蓝填充,未选中 slate 边框
**ExceptionWizard** — 5步向导控制器
- 管理 5 个步骤的切换(由 useWizardState hook 驱动)
- 渲染当前步骤组件,传递必要的 props
- 步骤条 UI:圆形步骤编号 + 连接线,已完成绿色/当前靛蓝/待完成 slate
**步骤组件 (StepSelect, StepListen, StepMatch, StepEdit, StepConfirm)**
- 每个步骤是独立组件
- 通过 props 接收:detailRecord, action, params, previewState
- 通过回调通知:onActionChange, onParamsChange, onPreview, onExecute
**RepairTaskPanel** — 修复任务面板
- 显示当前修复任务的进度
- WebSocket 实时日志流
- 可折叠设计
**ActionPreviewModal** — 操作预览弹窗
- 显示批量操作的预览结果
- 风险等级标识(低/中/高)
- 确认/取消操作
### 自定义 Hooks
**useExceptionSummary**
- 封装 fetchExceptionSummary API 调用
- 返回 { summary, error, refresh }
**useExceptionList**
- 封装 fetchExceptionItems API(分页/筛选/队列模式)
- 管理 items, total, page, filter 状态
- 返回 { items, total, page, filter, setFilter, setPage, refresh, loading, error }
**useExceptionDetail**
- 封装 fetchExceptionItem API
- 由 selectedExceptionId 驱动自动获取
- 返回 { detail, loading, error, refresh }
**useRepairTask**
- 封装 WebSocket 连接 + repair task 状态
- 管理 repairTask, repairLogs
- 自动处理重连
**useWizardState**
- 管理:wizardStep, selectedAction, actionParams, previewState, executeError
- 步骤切换逻辑:inferDefaultAction, buildDefaultParams
- 返回 { wizardStep, setWizardStep, selectedAction, setSelectedAction, ... }
### 数据流
```
ExceptionPage (顶层)
├─ useExceptionSummary() ──→ ExceptionStatsBar
├─ useExceptionList() ──→ 左侧歌曲队列
├─ useExceptionDetail() ──→ 当前选中歌曲详情
├─ useWizardState() ──→ ExceptionWizard → 5 步骤组件
└─ useRepairTask() ──→ RepairTaskPanel
```
数据通过 props 向下流动,回调通过 props 向上通知。不使用 Context(避免不必要的心智负担)。
## 交互优化
### 平滑过渡
- 步骤切换:CSS transition 淡入淡出(150ms
- 列表项选择:背景色过渡(150ms)
- 弹窗:fade + scale 动画(200ms
### 微交互
- 悬停状态:列表项 hover 时背景微亮(slate-700
- 操作按钮:hover 时颜色加深 10%
- 统计卡片:hover 时轻微上浮 (translateY -2px)
- 加载状态:骨架屏替代纯 spinner
### 键盘导航
- ← → 键切换向导步骤
- ↑ ↓ 键在歌曲队列中导航
- Enter 确认当前操作
- Escape 关闭弹窗
## 5步向导流程(保持现有逻辑)
| 步骤 | 名称 | 功能 | 可操作 |
|------|------|------|--------|
| 1 | 选择歌曲 | 从异常队列选择歌曲 | 点击选中 |
| 2 | 试听确认 | 播放音频确认文件正确 | 播放/暂停 |
| 3 | 推荐匹配 | 查看元数据候选列表 | 选择候选/确认 |
| 4 | 手动编辑 | 编辑元数据字段 | 修改字段值 |
| 5 | 入库确认 | 确认目标路径并入库 | 预览/执行 |
步骤可自由前进后退(已确认的操作不丢失)。
## 兼容性
### 保留功能
- 高级列表视图模式(通过右上角切换入口)
- 批量操作(在高级列表模式下)
- 所有现有 API 端点不变
- 所有现有异常类型和操作动作
### 浏览器支持
- Chrome, Firefox, Safari, Edge 最近 2 个主版本
- 不需要 IE 支持
## 非目标(不在本次范围)
- 后端 API 修改
- 新增异常类型
- 新增修复操作类型
- 国际化(i18n
- 移动端适配(保持桌面端优先,响应式布局兜底)
## 实现顺序
1. **Phase 1**: 提取自定义 hooks(纯逻辑层,不改变 UI)
2. **Phase 2**: 拆分步骤组件(保持原有 5 步逻辑)
3. **Phase 3**: 重构 ExceptionPage 容器(集成 hooks + 组件)
4. **Phase 4**: 应用深色专业主题样式
5. **Phase 5**: 添加过渡动画和微交互
6. **Phase 6**: 更新高级列表视图样式
7. **Phase 7**: 测试与验证
每个 Phase 完成后进行功能验证,确保不引入回归。
@@ -0,0 +1,157 @@
# WorkbenchPage 重构 — 设计规格
> 创建日期: 2026-05-07
> 状态: 设计中
## 概述
将 WorkbenchPage.jsx 从 ~800 行单体组件重构为 5 个聚焦组件 + 2 个自定义 hooks 的模块化架构,继承深色专业主题风格。
### 核心痛点
- 所有逻辑在一个文件(任务加载、WebSocket、配置展示、进度可视化、5阶段统计、文件列表、日志流)
- 事件处理爆炸(handleStreamEvent 处理 10+ 种 WebSocket 事件类型)
- 统计展示冗余(5 个阶段 40+ 个 StatCard 重复渲染)
- 配置和控制耦合(目录配置展示和任务启动/监控逻辑混在一起)
## 设计决策
| 维度 | 决策 |
|------|------|
| 视觉风格 | 继承深色专业主题(indigo/slate,与异常中心一致) |
| 组件拆分 | 6 个功能组件 + 2 个自定义 hooks |
| 事件处理 | `useTaskStream` hook 封装 WebSocket 事件处理 |
| 统计面板 | `StageStatsPanel` 通过 stage/stats props 消除 40+ 重复 StatCard |
| 后端改动 | 无需改动 |
## 组件架构
```
src/
├── pages/
│ └── WorkbenchPage.jsx # ~120行容器(从~800行精简)
├── components/workbench/
│ ├── TaskControlPanel.jsx # 目录配置展示 + 启动/重试按钮
│ ├── TaskProgressBar.jsx # 5阶段进度条(Scan→Preprocess→Match→Dedupe→Organize
│ ├── StageStatsPanel.jsx # 5阶段统计面板(消除40+重复StatCard
│ ├── TaskInfoPanel.jsx # 当前任务基本信息
│ ├── TaskFileList.jsx # 任务文件列表
│ └── TaskLogStream.jsx # 实时日志流
├── hooks/
│ ├── useTaskStream.js # WebSocket 连接 + 10+ 事件类型处理
│ └── useTaskRunner.js # 任务启动/状态管理
└── utils/
└── workbench.js # 阶段定义、空统计模板等共享常量
```
### 组件职责
**WorkbenchPage** — 页面容器(~120行)
- 通过 useTaskStream 和 useTaskRunner 管理顶层状态
- 将 task/items/logs/config 通过 props 分发给子组件
- 不包含任何渲染细节
**TaskControlPanel** — 任务控制面板
- Props: config, canStart, isRunning, isCompleted, isFailed, onStart, isStarting, errorMessage
- 显示三个目录配置(输入/输出/回收站)
- 根据状态显示不同的启动按钮和提示
**TaskProgressBar** — 5阶段进度条
- Props: task, isRunning, isCompleted, isFailed, progressWidth, progressLabel, latestFile
- 5个圆形步骤节点 + 连接线 + 进度条
- 状态颜色:running=emerald/active=emerald/completed=emerald/failed=rose
**StageStatsPanel** — 阶段统计面板
- Props: stageName, stats, stageConfig
- 通过 STAGE_STATS_CONFIG 自动渲染该阶段的所有统计卡片
- 消除原来 5 个阶段的重复 StatCard 排列
**TaskInfoPanel** — 任务信息面板
- Props: task, latestLogId, hasMoreLogs
- 显示任务状态、当前阶段、开始/完成时间等基本信息
**TaskFileList** — 任务文件列表
- Props: items, isLoading
- 文件项展示:路径、封面/歌词状态、各阶段状态徽章
**TaskLogStream** — 实时日志流
- Props: logs, logsEndRef
- 分色日志行(error=rose/success=emerald/warning=amber/info=blue
### 自定义 Hooks
**useTaskStream**
- 封装 WebSocket 连接(createTaskStream
- 处理 10+ 事件类型:task.snapshot, task.started, stage.started, *.progress, stage.completed, task.completed, task.failed, log.appended
- 管理 task, items, logs 状态
- 返回 { task, items, logs, latestFile, hasMoreLogs, latestLogId, loadCurrentTask, startTask, ... }
**useTaskRunner**
- 封装任务启动/停止逻辑
- runTask API 调用 + 409 冲突处理(已有运行中任务)
- 返回 { isStarting, errorMessage, handleStart }
### 数据流
```
WorkbenchPage (顶层 ~120行)
├─ useTaskStream() ──→ WebSocket → task/items/logs 状态
├─ useTaskRunner() ──→ 启动/停止逻辑
├─ TaskControlPanel ← config, canStart, isRunning, isStarting
├─ TaskProgressBar ← task, stageIndex, progressWidth
├─ StageStatsPanel ×5 ← task.stats.{scan,preprocess,match,dedupe,organize}
├─ TaskInfoPanel ← task 基本信息
├─ TaskFileList ← items
└─ TaskLogStream ← logs
```
## 阶段统计配置(消除重复)
```javascript
// utils/workbench.js
export const STAGE_STATS_CONFIG = {
scan: [
{ key: 'total_found', label: '候选音频', color: 'white' },
{ key: 'queued', label: '成功入队', color: 'emerald' },
{ key: 'skipped_locked', label: '最近写入跳过', color: 'amber' },
{ key: 'skipped_invalid', label: '无效文件', color: 'rose', error: true },
// { key: 'ignored_non_audio', label: '静默忽略非音频', fullWidth: true }
],
preprocess: [
{ key: 'input_items', label: '输入项目', color: 'white' },
{ key: 'output_items', label: '有效输出', color: 'emerald' },
// ... 其余字段
],
match: [ /* ... */ ],
dedupe: [ /* ... */ ],
organize: [ /* ... */ ]
};
```
StageStatsPanel 根据 stageName 查找配置,自动生成 `<StatCard>` 列表。
## 兼容性
- 保留所有现有功能不变
- props 接口与现有 constants/STAGES 定义兼容
- WebSocket 事件处理逻辑不变,仅位置改变
- MissingTagsInlinePanel 保持不变
## 非目标(不在本次范围)
- 后端 API 修改
- 新增任务类型
- 配置表单重构(SettingsPage 独立优化)
- MissingTagsInlinePanel 整合(后续单独优化)
## 实现顺序
1. **Phase 1**: 提取共享常量到 `utils/workbench.js`
2. **Phase 2**: 创建 `useTaskStream` hook(最复杂,WebSocket 事件处理)
3. **Phase 3**: 创建 `useTaskRunner` hook(任务启动逻辑)
4. **Phase 4**: 创建 StageStatsPanel 组件(消除最大重复)
5. **Phase 5**: 创建其余 5 个展示组件
6. **Phase 6**: 重写 WorkbenchPage 为轻量容器
7. **Phase 7**: 构建验证
@@ -0,0 +1,139 @@
# SettingsPage 重构 — 设计规格
> 创建日期: 2026-05-08
> 状态: 设计中
## 概述
将 SettingsPage.jsx 从 ~700 行单体组件重构为 8 个聚焦组件 + 1 个自定义 hook 的模块化架构,继承深色专业主题风格。
### 核心痛点
- 所有配置节(路径/调度/通知/元数据服务)内联渲染
- 导出/导入对话框逻辑(~150 行)耦合在页面组件中
- 调度 cron 解析逻辑(~140 行工具函数)占据文件头部
- Toast 通知、元数据连通性检测、网络状态管理混杂在一处
## 设计决策
| 维度 | 决策 |
|------|------|
| 视觉风格 | 继承深色专业主题(indigo/slate |
| 组件拆分 | 5 个配置节组件 + 2 个对话框组件 + 1 个服务状态组件 |
| 状态管理 | `useSettingsForm` hook 封装 localConfig、调度更新逻辑 |
| 后端改动 | 无需改动 |
## 组件架构
```
src/
├── pages/
│ └── SettingsPage.jsx # ~100行容器
├── components/settings/
│ ├── CorePathsSection.jsx # 核心目录配置(输入/输出/回收站)
│ ├── ScheduleSection.jsx # 定时调度配置(每日/每周/Cron)
│ ├── AdvancedStrategySection.jsx # 高级策略(元数据回退、素材下载等)
│ ├── NotificationSection.jsx # 通知配置(钉钉/Telegram/邮件)
│ ├── MetadataServicesSection.jsx # 元数据服务配置(8个provider
│ ├── ConfigExportDialog.jsx # 配置导出对话框
│ ├── ConfigImportDialog.jsx # 配置导入对话框
│ └── ServiceStatusBadge.jsx # 服务连通性状态徽章
├── hooks/
│ └── useSettingsForm.js # 配置表单状态 + 调度更新 + 导出/导入
└── utils/
└── schedule.js # 调度工具函数(从 SettingsPage 头部抽取)
```
### 组件职责
**SettingsPage** — 页面容器(~100行)
- 通过 useSettingsForm hook 管理配置状态
- 渲染 5 个配置节组件 + 2 个对话框
- 不包含任何配置渲染细节
**CorePathsSection** — 核心目录配置
- Props: input, output, trash, onUpdate
- 三个文本输入字段(输入目录/输出目录/回收站)
**ScheduleSection** — 定时调度配置
- Props: schedule, onUpdate
- 三种调度类型选择(每日/每周/Cron)+ 时间选择 + 星期选择
**AdvancedStrategySection** — 高级策略配置
- Props: advancedStrategy, onUpdate
- 元数据回退、素材下载等开关
**NotificationSection** — 通知配置
- Props: notifications, onUpdate
- 钉钉 Webhook/Secret、Telegram Bot Token/ChatID、Email SMTP 配置
**MetadataServicesSection** — 元数据服务配置
- Props: metadata, netStatus, onUpdate
- 8 个元数据 provider 的 URL/Key 配置 + 连通性状态徽章
**ConfigExportDialog** — 导出对话框
- Props: isOpen, isExporting, password, passwordConfirm, onClose, onExport, onPasswordChange
- 密码确认 + 导出按钮
**ConfigImportDialog** — 导入对话框(多阶段:解密密码 → 确认 → 保存)
- Props: importDialog state, onClose, onDecrypt, onConfirm
- 文件选择 → 密码输入 → 配置预览确认
**ServiceStatusBadge** — 服务状态徽章
- Props: status, latencyMs, message
- 探测中/在线/警告/离线/未检测 五种状态
### 自定义 Hook
**useSettingsForm**
- 封装 localConfig 状态管理
- 调度类型/时间/日期/Cron 更新函数
- 保存/导出/导入/网络检测逻辑
- Toast 通知状态
- 返回 { localConfig, netStatus, toast, isSaving, updateField, updateSchedule, handleSave, handleExport, handleImport, ... }
### 数据流
```
SettingsPage (顶层 ~100行)
├─ useSettingsForm() ──→ localConfig, updateField, updateSchedule, handleSave...
├─ CorePathsSection ← config.input/output/trash
├─ ScheduleSection ← config.schedule
├─ AdvancedStrategySection ← config.advancedStrategy
├─ NotificationSection ← config.notifications
├─ MetadataServicesSection ← config.metadata + netStatus
├─ ConfigExportDialog ← exportState, onClose, onExport
└─ ConfigImportDialog ← importState, onDecrypt, onConfirm
```
### 工具函数抽取
`scheduler.js` 从 SettingsPage 头部(1-142 行)抽取:
- `normalizeSchedule`, `getScheduleSummary`
- `buildDailyCron`, `buildWeeklyCron`
- `formatTimeFromCron`, `padCronSegment`
- `formatBackupTimestamp`
## 兼容性
- 保留所有现有功能不变
- `saveConfig` API 调用格式不变
- `fetchMetadataStatus` API 调用不变
- `handleSave` 同时更新父组件 config 和 taskState
## 非目标(不在本次范围)
- 后端 API 修改
- 新增配置项
- LibraryPage / HistoryPage / MissingTagsInlinePanel 优化(后续单独处理)
## 实现顺序
1. **Phase 1**: 抽取调度工具函数到 `utils/schedule.js`
2. **Phase 2**: 创建 `useSettingsForm` hook
3. **Phase 3**: 创建 5 个配置节组件
4. **Phase 4**: 创建 2 个对话框组件 + ServiceStatusBadge
5. **Phase 5**: 重写 SettingsPage 为轻量容器
6. **Phase 6**: 构建验证
+1
View File
@@ -0,0 +1 @@
{"env":{"browser":true,"es2021":true,"node":true},"extends":["eslint:recommended","plugin:react/recommended","plugin:react-hooks/recommended","plugin:jsx-a11y/recommended"],"parserOptions":{"ecmaFeatures":{"jsx":true},"ecmaVersion":"latest","sourceType":"module"},"root":true,"rules":{"no-console":["warn",{"allow":["warn","error"]}],"no-unused-vars":["warn",{"argsIgnorePattern":"^_"}],"react/prop-types":"warn","react/react-in-jsx-scope":"off"},"settings":{"react":{"version":"detect"}}}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
build/
coverage/
.env
.env.local
.DS_Store
*.log
+1
View File
@@ -0,0 +1 @@
{"arrowParens":"always","bracketSpacing":true,"endOfLine":"lf","printWidth":100,"semi":true,"singleQuote":true,"tabWidth":2,"trailingComma":"es5"}
@@ -0,0 +1,909 @@
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import {
CheckCircle2,
Headphones,
LoaderCircle,
Music2,
Pause,
Play,
Search,
ShieldAlert,
Sparkles,
Trash2
} from 'lucide-react';
import {
buildExceptionAudioUrl,
executeExceptionAction,
fetchExceptionItem,
fetchExceptionItems,
previewExceptionAction
} from '../api/exceptions';
import {
createRepairTaskStream,
fetchCurrentRepairTask,
fetchRepairTask,
fetchRepairTaskLogs
} from '../api/repairs';
// ── Constants (mirrored from ExceptionPage.jsx) ──────────────────────────────
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'] }
];
const METADATA_FIELDS = ['title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics'];
const REQUIRED_FIELDS = ['title', 'artist', 'album_artist'];
const METADATA_QUEUE_TYPES = ['missing_tags'];
const METADATA_QUEUE_PAGE_SIZE = 100;
// ── Utility functions (mirrored from ExceptionPage.jsx) ────────────────────
function chipClass(active) {
return `rounded-full border px-3 py-1.5 text-xs transition ${
active
? 'border-cyan-400/50 bg-cyan-500/15 text-cyan-100'
: 'border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-500'
}`;
}
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-cyan-400/60';
}
function actionButtonClass(enabled) {
return `inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-xs font-medium transition ${
enabled
? 'border border-cyan-400/40 bg-cyan-500/15 text-cyan-100 hover:bg-cyan-500/20'
: 'cursor-not-allowed border border-slate-800 bg-slate-900 text-slate-600'
}`;
}
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'];
}
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 value; }
}
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')}`;
}
function formatConfidence(value) {
if (value == null) return '--';
return `${Number(value).toFixed(1)}`;
}
function formatMetadataValue(value) {
if (value === null || value === undefined || value === '') return '--';
return String(value);
}
function providerLabel(provider) {
const labels = { acoustid: 'AcoustID', musicbrainz: 'MusicBrainz', netease: '网易云', qq: 'QQ 音乐', spotify: 'Spotify' };
const key = String(provider || '').toLowerCase();
return labels[key] || provider || '推荐候选';
}
function compareTimestampDesc(a, b) {
return new Date(b || 0).getTime() - new Date(a || 0).getTime();
}
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;
}
function getMissingRequiredFields(metadata) {
return REQUIRED_FIELDS
.filter((field) => !String(metadata?.[field] || '').trim())
.map((field) => {
const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' };
return labels[field] || field;
});
}
function isTerminalRepairStatus(status) {
return status === 'completed' || status === 'failed';
}
// ── Sub-components ──────────────────────────────────────────────────────────
function InfoField({ label, value, mono = false }) {
return (
<div className="rounded-2xl 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>
);
}
function ErrorText({ message }) {
return <p className="mt-3 text-xs text-rose-300">{message}</p>;
}
function renderInlineBadge(status) {
if (!status) return null;
const map = {
submitting: 'border-amber-500/30 bg-amber-500/10 text-amber-200',
accepted: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200',
running: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-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>;
}
// Memoized queue item component to prevent unnecessary re-renders
const QueueItem = memo(({ item, selectedId, onSelect }) => {
const ap = item.audio_props_json || {};
const selected = selectedId === item.exception_id;
return (
<button
onClick={() => onSelect(item.exception_id)}
className={`w-full rounded-2xl border p-4 text-left transition ${
selected ? 'border-cyan-400/60 bg-cyan-500/10 shadow-[0_0_0_1px_rgba(34,211,238,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-cyan-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>{formatSeconds(ap.duration_seconds)}</span>
<span className="text-right">{ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'}</span>
</div>
</button>
);
});
// ── Main component ──────────────────────────────────────────────────────────
export default function MissingTagsInlinePanel({
onSwitchToAdvanced
}) {
// Queue state
const [queue, setQueue] = useState([]);
const [isQueueLoading, setIsQueueLoading] = useState(true);
const [queueError, setQueueError] = useState('');
// Selected item state
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
// Metadata editor state
const [metadataPatch, setMetadataPatch] = useState({});
const [providerMode, setProviderMode] = useState('all');
const [providers, setProviders] = useState([]);
// Combined state using useReducer
const [previewState, setPreviewState] = useReducer(
(state, action) => {
switch (action.type) {
case 'START': return { ...state, loading: true, error: '', action: action.action };
case 'SUCCESS': return { ...state, loading: false, payload: action.payload, error: '' };
case 'ERROR': return { ...state, loading: false, payload: null, error: action.error };
case 'CLEAR': return { loading: false, payload: null, error: '', action: '' };
default: return state;
}
},
{ loading: false, payload: null, error: '', action: '' }
);
const [executionState, setExecutionState] = useReducer(
(state, action) => {
switch (action.type) {
case 'SUBMIT': return { ...action.payload, status: 'submitting', repairTaskId: null, error: '' };
case 'ACCEPT': return { ...state, status: 'accepted', repairTaskId: action.repairTaskId, error: '' };
case 'RUNNING': return { ...state, status: 'running' };
case 'COMPLETE': return { ...state, status: 'completed' };
case 'FAIL': return { ...state, status: 'failed', error: action.error };
case 'CLEAR': return null;
default: return state;
}
},
null
);
const [repairTask, setRepairTask] = useState(null);
const [repairLogs, setRepairLogs] = useState([]);
const completedRefreshRef = useRef(new Set());
// Derived values
const draft = metadataPatch && Object.keys(metadataPatch).length > 0
? metadataPatch
: detail?.effective_metadata || {};
const missingFields = getMissingRequiredFields(draft);
const canIngest = missingFields.length === 0 && (detail?.available_actions || []).includes('save_and_organize');
const candidates = detail?.match_candidates_json || [];
const finalPreviewItem = previewState.action === 'save_and_organize' && previewState.payload
? previewState.payload.items?.find((item) => item.exception_id === selectedId) || null
: null;
const finalPreview = finalPreviewItem?.final_library_preview || null;
// ── Load queue ──────────────────────────────────────────────────────────
const loadQueue = useCallback((keepSelection = true) => {
setIsQueueLoading(true);
setQueueError('');
Promise.all(
METADATA_QUEUE_TYPES.map((type) =>
fetchExceptionItems({ type, resolutionStatus: 'open', page: 1, pageSize: METADATA_QUEUE_PAGE_SIZE })
)
)
.then((payloads) => {
const all = payloads
.flatMap((p) => p.items)
.sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at));
setQueue(all);
if (keepSelection) {
setSelectedId((prev) => {
if (!all.length) return null;
if (prev && all.some((item) => item.exception_id === prev)) return prev;
return all[0]?.exception_id || null;
});
}
})
.catch((err) => setQueueError(err.message || '队列加载失败'))
.finally(() => setIsQueueLoading(false));
}, []);
// ── Load detail ──────────────────────────────────────────────────────────
useEffect(() => {
if (!selectedId) { setDetail(null); return; }
const controller = new AbortController();
setIsDetailLoading(true);
setDetailError('');
fetchExceptionItem(selectedId, { signal: controller.signal })
.then((payload) => {
setDetail(payload);
const md = payload.effective_metadata || payload.matched_metadata_json || payload.original_tags_json || {};
setMetadataPatch({
title: md.title || '', artist: md.artist || '', album: md.album || '',
album_artist: md.album_artist || '', track_number: md.track_number ?? null,
disc_number: md.disc_number ?? null, year: md.year ?? null, lyrics: md.lyrics || ''
});
setPreviewState({ type: 'CLEAR' });
setExecutionState({ type: 'CLEAR' });
})
.catch((err) => {
if (err.name !== 'AbortError') { setDetail(null); setDetailError(err.message || '详情加载失败'); }
})
.finally(() => { if (!controller.signal.aborted) setIsDetailLoading(false); });
return () => controller.abort();
}, [selectedId]);
// ── Initial load ─────────────────────────────────────────────────────────
useEffect(() => { loadQueue(false); }, []);
useEffect(() => {
fetchCurrentRepairTask().then((p) => {
if (p.task) { setRepairTask(p.task); fetchRepairTaskLogs(p.task.task_id, 1, 20).then((lp) => setRepairLogs(lp.logs)); }
}).catch(() => {});
}, []);
// ── Repair task WebSocket ────────────────────────────────────────────────
useEffect(() => {
if (!repairTask?.task_id) return;
let socket = null;
let isMounted = true;
const setupSocket = () => {
socket = createRepairTaskStream(repairTask.task_id);
socket.onmessage = async (event) => {
if (!isMounted) return;
const p = JSON.parse(event.data);
if (p.type === 'task.snapshot') {
setRepairTask(p.data.task);
setRepairLogs(p.data.recent_logs || []);
return;
}
try {
const tp = await fetchRepairTask(repairTask.task_id);
const lp = await fetchRepairTaskLogs(repairTask.task_id, 1, 20);
if (isMounted) {
setRepairTask(tp.task);
setRepairLogs(lp.logs);
}
} catch (err) {
console.error('Failed to refresh repair task state', err);
}
};
socket.onerror = (err) => {
console.error('Repair task WebSocket error', err);
};
};
setupSocket();
return () => {
isMounted = false;
if (socket) {
try {
socket.close();
} catch (err) {
console.warn('WebSocket close error', err);
}
socket = null;
}
};
}, [repairTask?.task_id]);
// ── React to repair task completion ─────────────────────────────────────
useEffect(() => {
if (!repairTask?.task_id) return;
setExecutionState((prev) => {
if (!prev || prev.repairTaskId !== repairTask.task_id) return prev;
const nextStatus = repairTask.status === 'completed' ? 'completed'
: repairTask.status === 'failed' ? 'failed'
: repairTask.status === 'running' ? 'running' : 'accepted';
if (prev.status === nextStatus) return prev;
return { ...prev, status: nextStatus, error: repairTask.status === 'failed' ? repairTask.error_message || '执行失败' : '' };
});
if (isTerminalRepairStatus(repairTask.status) && !completedRefreshRef.current.has(repairTask.task_id)) {
completedRefreshRef.current.add(repairTask.task_id);
loadQueue(true);
}
}, [repairTask]);
// ── Metadata update handler ──────────────────────────────────────────────
const updateMetadata = useCallback((key, value) => {
setMetadataPatch((prev) => ({ ...prev, [key]: value }));
setPreviewState({ type: 'CLEAR' });
}, []);
// ── Preview handler ──────────────────────────────────────────────────────
const handlePreview = useCallback(async (action) => {
if (!selectedId || !action) return;
const params = normalizeActionParams(action, {
metadata_patch: metadataPatch,
provider_mode: providerMode,
providers
});
setPreviewState({ type: 'START', action });
try {
const payload = await previewExceptionAction({ exception_ids: [selectedId], action, params });
setPreviewState({ type: 'SUCCESS', payload });
} catch (err) {
setPreviewState({ type: 'ERROR', error: err.message || '预览生成失败' });
}
}, [selectedId, metadataPatch, providerMode, providers]);
// ── Execute handler ──────────────────────────────────────────────────────
const handleExecute = useCallback(async (action) => {
if (!selectedId || !action) return;
if (action === 'delete_file') {
if (!window.confirm('将永久删除选中的文件,且无法恢复。是否继续?')) return;
if (!window.confirm('请再次确认:这会真实删除文件,不是忽略。是否执行删除?')) return;
}
setExecutionState({ type: 'SUBMIT', payload: { exceptionId: selectedId, action, submittedAt: new Date().toISOString() } });
try {
const params = normalizeActionParams(action, {
metadata_patch: metadataPatch,
provider_mode: providerMode,
providers
});
const payload = await executeExceptionAction({ exception_ids: [selectedId], action, params });
setExecutionState({ type: 'ACCEPT', repairTaskId: payload.repair_task_id });
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) {
setExecutionState({ type: 'FAIL', error: err.message || '执行失败' });
}
}, [selectedId, metadataPatch, providerMode, providers]);
// ── Smart ingest: preview then execute ────────────────────────────────────
const handleIngest = useCallback(async () => {
if (!canIngest) return;
try {
const params = normalizeActionParams('save_and_organize', { metadata_patch: metadataPatch, provider_mode: providerMode, providers });
const previewP = await previewExceptionAction({ exception_ids: [selectedId], action: 'save_and_organize', params });
setPreviewState({ type: 'SUCCESS', payload: previewP, action: 'save_and_organize' });
// Directly execute after preview
await handleExecute('save_and_organize');
} catch (err) {
setPreviewState({ type: 'ERROR', error: err.message || '预览生成失败' });
// Also clear any stale execution state
setExecutionState({ type: 'CLEAR' });
}
}, [canIngest, metadataPatch, providerMode, providers, selectedId, handleExecute]);
// ── Audio player state ───────────────────────────────────────────────────
const audioRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [audioError, setAudioError] = useState('');
const audioUrl = detail ? buildExceptionAudioUrl(detail.exception_id) : '';
const audioProps = detail?.audio_props_json || {};
useEffect(() => {
setIsPlaying(false); setCurrentTime(0); setDuration(0); setAudioError('');
if (audioRef.current) { audioRef.current.pause(); audioRef.current.load(); }
}, [audioUrl]);
const togglePlay = useCallback(() => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play().catch(() => setAudioError('播放器启动失败'));
} else {
audioRef.current.pause();
}
}, []);
// ── Render ────────────────────────────────────────────────────────────────
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(56,189,248,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-cyan-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-cyan-300" />
元数据缺失处理
</h2>
<p className="mt-2 max-w-3xl text-sm text-slate-400">
左侧编辑元数据右侧实时预览入库路径补全必填字段后一键入库
</p>
</div>
<div className="flex items-start gap-3">
<button onClick={onSwitchToAdvanced} className={actionButtonClass(true)}>
高级处理
</button>
</div>
</div>
</section>
{/* Main content: queue + workspace */}
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[minmax(300px,0.32fr)_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">{queue.length} 个待处理</h3>
</div>
<div className="max-h-[calc(100vh-330px)] min-h-[320px] overflow-auto p-3">
{isQueueLoading ? (
<div className="flex min-h-[280px] items-center justify-center text-sm text-slate-500">正在加载...</div>
) : queueError ? (
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">{queueError}</div>
) : !queue.length ? (
<div className="flex min-h-[280px] flex-col items-center justify-center text-center text-slate-500">
<CheckCircle2 className="mb-4 h-12 w-12 text-emerald-300/60" />
<p className="text-sm text-slate-300">元数据缺失异常已处理完成</p>
</div>
) : (
<div className="space-y-2">
{queue.map((item) => (
<QueueItem
key={item.exception_id}
item={item}
selectedId={selectedId}
onSelect={setSelectedId}
/>
))}
</div>
)}
</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)]">
{!detail && !isDetailLoading ? (
<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>
) : isDetailLoading ? (
<div className="flex h-full min-h-[420px] items-center justify-center">
<LoaderCircle className="h-8 w-8 animate-spin text-cyan-300/60" />
</div>
) : (
<div className="space-y-4 pb-8">
{/* File summary */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">当前文件</p>
<h3 className="mt-2 truncate text-lg font-semibold text-white">{detail.display_title || '-'}</h3>
<p className="mt-1 truncate font-mono text-[11px] text-cyan-300/80">{detail.filename || '-'}</p>
</div>
<span className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2.5 py-1 text-xs text-rose-200">开放中</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-300">
<InfoField label="匹配来源" value={detail.match_source || '--'} />
<InfoField label="匹配分数" value={formatConfidence(detail.match_confidence)} />
<InfoField label="编码" value={audioProps.codec || '--'} />
<InfoField label="时长" value={formatSeconds(audioProps.duration_seconds)} />
</div>
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
{detail.display_reason || '-'}
</div>
</div>
{/* Two-column layout: editor | preview */}
<div className="grid gap-4 xl:grid-cols-[1fr_minmax(320px,0.9fr)]">
{/* ── Left column: Edit ─────────────────────────────────── */}
<div className="space-y-4">
{/* Audio player */}
<div className="rounded-[24px] border border-slate-800/80 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>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-300">
<InfoField label="格式" value={audioProps.format || '--'} />
<InfoField label="采样率" value={audioProps.sample_rate ? `${audioProps.sample_rate} Hz` : '--'} />
<InfoField label="比特率" value={audioProps.bitrate ? `${Math.round(audioProps.bitrate / 1000)} kbps` : '--'} />
<InfoField label="位深" value={audioProps.bit_depth ? `${audioProps.bit_depth} bit` : '--'} />
</div>
{audioError ? <p className="mt-3 text-xs text-rose-300">{audioError}</p> : null}
</div>
{/* Match retry */}
<div className="rounded-[24px] border border-slate-800/80 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={() => { handlePreview('retry_match'); }} className={actionButtonClass(true)}>
<Search className="h-3.5 w-3.5" />预览匹配
</button>
<button onClick={() => handleExecute('retry_match')}
className="rounded-xl bg-cyan-500 px-3 py-2 text-sm font-medium text-slate-950">
执行匹配
</button>
</div>
{previewState.action === 'retry_match' && previewState.loading ? (
<div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成匹配预览...
</div>
) : null}
{executionState?.action === 'retry_match' ? (
<div className="mt-3">{renderInlineBadge(executionState.status)}</div>
) : null}
</div>
{/* Metadata editor */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium text-white">元数据编辑</h4>
<p className="mt-2 text-xs leading-5 text-slate-400">补全 title / artist / album_artist 后可入库</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>
{detail.album_artist_reason ? (
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
{detail.album_artist_reason}
</div>
) : null}
<div className="mt-4 grid grid-cols-2 gap-2">
<input className={inputClass()} value={draft.title || ''} onChange={(e) => updateMetadata('title', e.target.value)} placeholder="标题 *" />
<input className={inputClass()} value={draft.artist || ''} onChange={(e) => updateMetadata('artist', e.target.value)} placeholder="艺术家 *" />
<input className={inputClass()} value={draft.album_artist || ''} onChange={(e) => updateMetadata('album_artist', e.target.value)} placeholder="专辑艺术家 *" />
<input className={inputClass()} value={draft.album || ''} onChange={(e) => updateMetadata('album', e.target.value)} placeholder="专辑" />
<input className={inputClass()} value={draft.track_number ?? ''} onChange={(e) => updateMetadata('track_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="曲目号" />
<input className={inputClass()} value={draft.disc_number ?? ''} onChange={(e) => updateMetadata('disc_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="碟号" />
<input className={inputClass()} value={draft.year ?? ''} onChange={(e) => updateMetadata('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) => updateMetadata('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-2xl 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>
{/* Candidate preview (if preview matched) */}
{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-2xl 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>
) : null}
{previewState.error && previewState.action === 'retry_match' ? <ErrorText message={previewState.error} /> : null}
</div>
</div>
{/* ── Right column: Preview ──────────────────────────────── */}
<div className="space-y-4">
{/* Refresh preview button */}
<div className="rounded-[24px] border border-slate-800/80 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={() => handlePreview('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-2xl 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' ? (
<ErrorText message={previewState.error} />
) : finalPreview ? (
<div className="mt-4 space-y-3">
{/* Target paths */}
<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>
{/* Final metadata table */}
<div className="overflow-x-auto rounded-2xl 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-cyan-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>
{/* Planned operations */}
<div className="rounded-2xl 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">
{(finalPreviewItem?.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>
) : null}
</div>
))}
</div>
{(finalPreviewItem?.warnings || []).concat(previewState.payload?.warnings || []).length > 0 ? (
<div className="mt-3 space-y-1 text-xs text-amber-200">
{finalPreviewItem?.warnings?.map((w, i) => <div key={`iw-${i}`}>{w}</div>)}
{previewState.payload?.warnings?.map((w, i) => <div key={`pw-${i}`}>{w}</div>)}
</div>
) : null}
</div>
</div>
) : null}
</div>
</div>
</div>
{/* ── Action bar ────────────────────────────────────────────── */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium text-white">执行操作</h4>
<p className="mt-1 text-xs text-slate-400">入库前请确认右侧预览结果忽略只改状态删除会真实删除文件</p>
</div>
{renderInlineBadge(executionState?.status)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{/* Primary: Ingest */}
<button onClick={handleIngest} disabled={!canIngest}
className={`rounded-xl px-3 py-2 text-sm font-medium transition ${
canIngest ? 'bg-cyan-500 text-slate-950 hover:bg-cyan-400' : 'cursor-not-allowed bg-slate-800 text-slate-500'
}`}>
入库
</button>
{/* Save draft */}
<button onClick={() => handleExecute('edit_metadata')}
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
保存草稿
</button>
{/* Ignore */}
<button onClick={() => { handlePreview('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={() => handleExecute('ignore_exception')}
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
确认忽略
</button>
{/* Delete */}
<button onClick={() => { handlePreview('delete_file'); }}
className="rounded-xl bg-rose-100 px-3 py-2 text-sm font-medium text-rose-950">
预览删除
</button>
<button onClick={() => handleExecute('delete_file')}
className="rounded-xl bg-rose-500 px-3 py-2 text-sm font-medium text-white">
删除文件
</button>
</div>
{/* Preview for ignore/delete */}
{(previewState.action === 'ignore_exception' || previewState.action === 'delete_file') && previewState.payload && !previewState.loading ? (
<div className="mt-4 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-2xl 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>
) : null}
{executionState?.error ? <ErrorText message={executionState.error} /> : null}
{/* Execution feedback */}
{executionState?.status ? (
<div className="mt-4 rounded-2xl border border-cyan-900/40 bg-cyan-950/20 p-3 text-xs text-cyan-100/85">
<div className="font-medium">
{executionState.status === 'submitting' ? '正在提交执行请求'
: executionState.status === 'accepted' ? '任务已提交'
: executionState.status === 'running' ? '任务执行中'
: executionState.status === 'completed' ? '执行完成'
: executionState.status === 'failed' ? '执行失败' : ''}
</div>
<div className="mt-1 text-slate-400">
{executionState.repairTaskId ? `任务号 ${executionState.repairTaskId}` : '等待返回任务号'}
{executionState.submittedAt ? `,提交时间 ${formatTimestamp(executionState.submittedAt)}` : ''}
</div>
{executionState.error ? <div className="mt-1 text-rose-200">{executionState.error}</div> : null}
</div>
) : null}
{/* Repair task logs */}
{repairTask && repairLogs.length > 0 && executionState?.repairTaskId === repairTask.task_id ? (
<div className="mt-4 rounded-2xl 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>
) : null}
</div>
</div>
)}
</section>
</div>
</div>
);
}