Compare commits
3 Commits
998658da7b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d7daa16bb | |||
| be3c086975 | |||
| 7d003ff822 |
@@ -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]
|
||||
@@ -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` 脚本)。
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 '匹配结果')
|
||||
@@ -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']
|
||||
]]>
|
||||
@@ -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**: 构建验证
|
||||
@@ -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"}}}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1 @@
|
||||
{"arrowParens":"always","bracketSpacing":true,"endOfLine":"lf","printWidth":100,"semi":true,"singleQuote":true,"tabWidth":2,"trailingComma":"es5"}
|
||||
@@ -1,143 +1,102 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Database,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
RefreshCw,
|
||||
Home,
|
||||
Settings,
|
||||
Wifi
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/workbench', label: '工作台', icon: LayoutDashboard },
|
||||
{ to: '/library', label: '音乐库', icon: Database },
|
||||
{ to: '/exceptions', label: '异常中心', icon: AlertTriangle },
|
||||
{ to: '/history', label: '任务历史', icon: History },
|
||||
{ to: '/settings', label: '系统配置', icon: Settings }
|
||||
// 导航项定义
|
||||
const navItems = [
|
||||
{ path: '/workbench', label: '工作台', icon: Home },
|
||||
{ path: '/library', label: '媒体库', icon: Database },
|
||||
{ path: '/exceptions', label: '异常', icon: TriangleAlert },
|
||||
{ path: '/history', label: '历史记录', icon: Clock },
|
||||
{ path: '/settings', label: '设置', icon: Settings },
|
||||
];
|
||||
|
||||
const PAGE_TITLES = {
|
||||
'/workbench': '工作台',
|
||||
'/library': '音乐库',
|
||||
'/exceptions': '异常中心',
|
||||
'/history': '任务历史',
|
||||
'/settings': '系统配置'
|
||||
};
|
||||
|
||||
export default function AppLayout({ connState, taskState }) {
|
||||
const location = useLocation();
|
||||
const pageTitle = PAGE_TITLES[location.pathname] || '工作台';
|
||||
const isWorkbenchPage = location.pathname === '/workbench';
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-slate-950 font-sans text-slate-300">
|
||||
<div className="flex w-64 flex-col border-r border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center space-x-3 p-6 text-emerald-400">
|
||||
<Activity className="h-8 w-8" />
|
||||
<span className="text-2xl font-bold tracking-wider text-white">音流工坊</span>
|
||||
</div>
|
||||
<div className="mb-2 px-4 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
主菜单
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-slate-800 p-4 text-xs text-slate-500">
|
||||
Navidrome Auto-Ingest Engine v1.2.0
|
||||
</div>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* 动态粒子背景(可选简单渐变,更高性能) */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 -left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-0 -right-1/4 w-96 h-96 bg-secondary/20 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900/50 px-6">
|
||||
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
{isWorkbenchPage && (
|
||||
<div className="flex items-center space-x-2 rounded-full border border-slate-700/50 bg-slate-800/50 px-3 py-1.5">
|
||||
{connState === 'connected' ? (
|
||||
<>
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<Wifi className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-xs font-medium text-emerald-400">
|
||||
实时连接中 (WS)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-amber-400" />
|
||||
<span className="text-xs font-medium text-amber-400">轮询兜底中</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-slate-400">系统状态:</span>
|
||||
<span
|
||||
className={`rounded px-2.5 py-1 text-xs font-semibold ${
|
||||
taskState === 'unconfigured'
|
||||
? 'bg-slate-800 text-slate-400'
|
||||
: taskState === 'ready'
|
||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||
: taskState === 'running'
|
||||
? 'border border-emerald-500/30 bg-emerald-500/20 text-emerald-400'
|
||||
: taskState === 'failed'
|
||||
? 'border border-rose-500/30 bg-rose-500/20 text-rose-400'
|
||||
: 'bg-slate-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{taskState === 'unconfigured'
|
||||
? '未配置'
|
||||
: taskState === 'ready'
|
||||
? '已配置,待机中'
|
||||
: taskState === 'running'
|
||||
? '任务执行中'
|
||||
: taskState === 'failed'
|
||||
? '任务失败'
|
||||
: '批次完成'}
|
||||
{/* 顶部玻璃导航栏 */}
|
||||
<header className="sticky top-0 z-50 backdrop-blur-xl bg-slate-900/60 border-b border-white/10">
|
||||
<div className="max-w-[1800px] mx-auto px-6 py-3 flex items-center justify-between">
|
||||
{/* Logo + 状态指示器 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/workbench" className="flex items-center gap-2 group">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary shadow-glow" />
|
||||
<h1 className="text-xl font-bold gradient-text">MusicWorkshop</h1>
|
||||
</Link>
|
||||
<div className="h-6 w-px bg-white/20" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
connState === 'connected' ? 'bg-emerald-500 animate-pulse' :
|
||||
connState === 'connecting' ? 'bg-yellow-500' :
|
||||
connState === 'reconnecting' ? 'bg-orange-500 animate-pulse' :
|
||||
'bg-slate-500'
|
||||
}`} />
|
||||
<span className="text-xs text-slate-400">
|
||||
{connState === 'connected' ? '实时连接' :
|
||||
connState === 'connecting' ? '连接中...' :
|
||||
connState === 'reconnecting' ? '重连中...' :
|
||||
connState === 'closed' ? '未连接' : '空闲'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={`flex-1 min-h-0 p-6 ${
|
||||
location.pathname === '/workbench'
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto'
|
||||
}`}
|
||||
>
|
||||
{/* 导航链接 */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-white/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 移动端菜单按钮 (简化,未完整实现) */}
|
||||
<button className="md:hidden p-2 rounded-lg hover:bg-white/10">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className={`max-w-[1800px] mx-auto px-6 py-4 ${
|
||||
location.pathname === '/settings'
|
||||
? 'overflow-y-auto'
|
||||
: 'h-[calc(100vh-64px)] overflow-hidden'
|
||||
}`}>
|
||||
<div className={`animate-fade-in h-full ${
|
||||
location.pathname === '/settings' ? '' : 'flex flex-col'
|
||||
}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButton({ to, icon: Icon, label }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex w-full items-center space-x-3 rounded-lg px-3 py-3 transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'hover:bg-slate-800/50 hover:text-slate-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
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';
|
||||
import MatchRunFeedback from './exceptions/MatchRunFeedback';
|
||||
import { candidateSignature } from '../utils/exceptions';
|
||||
|
||||
// ── 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') {
|
||||
const providerMode = params.provider_mode || 'all';
|
||||
const selectedMode = PROVIDER_MODES.find((mode) => mode.id === providerMode);
|
||||
return { provider_mode: providerMode, providers: selectedMode?.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');
|
||||
|
||||
// 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]);
|
||||
|
||||
// ── Re-fetch current selected detail (用于任务完成后刷新) ────────────────
|
||||
const refreshSelectedDetail = useCallback(async ({ clearTransient = false } = {}) => {
|
||||
if (!selectedId) return;
|
||||
setIsDetailLoading(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const payload = await fetchExceptionItem(selectedId);
|
||||
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 || ''
|
||||
});
|
||||
if (clearTransient) {
|
||||
setPreviewState({ type: 'CLEAR' });
|
||||
setExecutionState({ type: 'CLEAR' });
|
||||
}
|
||||
} catch (err) {
|
||||
setDetailError(err.message || '详情加载失败');
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
}, [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);
|
||||
refreshSelectedDetail({ clearTransient: false });
|
||||
}
|
||||
}, [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
|
||||
});
|
||||
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]);
|
||||
|
||||
// ── Execute handler ──────────────────────────────────────────────────────
|
||||
const handleExecute = useCallback(async (action) => {
|
||||
if (!selectedId || !action) return;
|
||||
if (action === 'delete_file') {
|
||||
if (!window.confirm('将永久删除选中的文件,且无法恢复。是否继续?')) return;
|
||||
if (!window.confirm('请再次确认:这会真实删除文件,不是忽略。是否执行删除?')) return;
|
||||
}
|
||||
const params = normalizeActionParams(action, {
|
||||
metadata_patch: metadataPatch,
|
||||
provider_mode: providerMode
|
||||
});
|
||||
setExecutionState({ type: 'SUBMIT', payload: {
|
||||
exceptionId: selectedId,
|
||||
action,
|
||||
submittedAt: new Date().toISOString(),
|
||||
requestedProviderMode: providerMode,
|
||||
requestedProviders: params.providers,
|
||||
submittedParams: params,
|
||||
beforeCandidateSignature: action === 'retry_match' ? candidateSignature(detail?.match_candidates_json || []) : ''
|
||||
} });
|
||||
try {
|
||||
// params 已在上方计算
|
||||
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]);
|
||||
|
||||
// ── 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 });
|
||||
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, 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)}
|
||||
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' ? (
|
||||
<MatchRunFeedback
|
||||
executionState={executionState}
|
||||
repairTask={repairTask}
|
||||
repairLogs={repairLogs}
|
||||
detail={detail}
|
||||
/>
|
||||
) : 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>
|
||||
{/* retry_match 已用 MatchRunFeedback 展示,不重复显示 */}
|
||||
{executionState?.action !== 'retry_match' && 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// frontend/src/components/exceptions/ExceptionWizard.jsx
|
||||
import { useEffect } from 'react';
|
||||
import { Music2, Wrench, Check } from 'lucide-react';
|
||||
import { WIZARD_STEPS, actionButtonClass, getMissingRequiredFields } from '../../utils/exceptions';
|
||||
import ExceptionStatsBar from './ExceptionStatsBar';
|
||||
@@ -28,6 +29,49 @@ export default function ExceptionWizard({
|
||||
const missingFields = getMissingRequiredFields(draft);
|
||||
const canIngest = missingFields.length === 0 && (detailRecord?.available_actions || []).includes('save_and_organize');
|
||||
|
||||
// 当前异常类型 — 用于统计卡高亮
|
||||
const activeExceptionType = detailRecord?.exception_type || null;
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handler = (e) => {
|
||||
// 不干扰可交互元素
|
||||
const tag = e.target?.tagName?.toLowerCase();
|
||||
if (
|
||||
tag === 'input' || tag === 'textarea' || tag === 'select' ||
|
||||
tag === 'button' || tag === 'a' ||
|
||||
e.target?.isContentEditable
|
||||
) return;
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const idx = metadataQueue.findIndex((i) => i.exception_id === selectedExceptionId);
|
||||
if (idx > 0) {
|
||||
const prev = metadataQueue[idx - 1];
|
||||
onSelectItem(prev.exception_id);
|
||||
onSetWizardStep('listen');
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const idx = metadataQueue.findIndex((i) => i.exception_id === selectedExceptionId);
|
||||
if (idx >= 0 && idx < metadataQueue.length - 1) {
|
||||
const next = metadataQueue[idx + 1];
|
||||
onSelectItem(next.exception_id);
|
||||
onSetWizardStep('listen');
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
// Enter: 在 listen 步骤时跳到 match
|
||||
if (wizardStep === 'listen') {
|
||||
e.preventDefault();
|
||||
onSetWizardStep('match');
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [metadataQueue, selectedExceptionId, wizardStep, onSelectItem, onSetWizardStep]);
|
||||
|
||||
const renderStep = () => {
|
||||
switch (wizardStep) {
|
||||
case 'select':
|
||||
@@ -41,7 +85,7 @@ export default function ExceptionWizard({
|
||||
/>
|
||||
);
|
||||
case 'listen':
|
||||
return <StepListen detailRecord={detailRecord} isLoading={isDetailLoading} />;
|
||||
return <StepListen detailRecord={detailRecord} isLoading={isDetailLoading} onSetWizardStep={onSetWizardStep} />;
|
||||
case 'match':
|
||||
return (
|
||||
<StepMatch
|
||||
@@ -49,6 +93,7 @@ export default function ExceptionWizard({
|
||||
providerMode={providerMode} setProviderMode={setProviderMode}
|
||||
providers={providers} setProviders={setProviders}
|
||||
previewState={previewState} executionState={executionState}
|
||||
repairTask={repairTask} repairLogs={repairLogs}
|
||||
onPreview={onPreview} onExecute={onExecute}
|
||||
/>
|
||||
);
|
||||
@@ -79,7 +124,7 @@ export default function ExceptionWizard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-120px)] flex-col gap-6 py-6">
|
||||
<div className="flex min-h-[calc(100vh-64px)] flex-col gap-4 py-2">
|
||||
<section className="rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_left,_rgba(99,102,241,0.08),_transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
|
||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
@@ -97,18 +142,24 @@ export default function ExceptionWizard({
|
||||
metadataTotal={metadataTotal}
|
||||
metadataQueueCounts={metadataQueueCounts}
|
||||
viewMode="wizard"
|
||||
activeExceptionType={activeExceptionType}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<WizardProgressBar currentStep={wizardStep} onSelectStep={onSetWizardStep} />
|
||||
<button onClick={onSwitchToAdvanced} className={actionButtonClass(true)}>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
高级处理
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
{repairTask && (
|
||||
<RepairTaskPanel variant="compact" repairTask={repairTask} repairLogs={repairLogs} executionState={executionState} />
|
||||
)}
|
||||
<button onClick={onSwitchToAdvanced} className={actionButtonClass(true)}>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
高级处理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[minmax(320px,0.42fr)_minmax(0,1fr)]">
|
||||
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<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>
|
||||
@@ -127,7 +178,7 @@ export default function ExceptionWizard({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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)]">
|
||||
<section className="min-h-0 rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_right,_rgba(34,197,94,0.08),_transparent_28%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
|
||||
{!detailRecord && !isDetailLoading && wizardStep !== 'select' ? (
|
||||
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-slate-500">
|
||||
<Music2 className="mb-4 h-12 w-12 opacity-30" />
|
||||
@@ -136,18 +187,22 @@ export default function ExceptionWizard({
|
||||
) : detailError ? (
|
||||
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">{detailError}</div>
|
||||
) : (
|
||||
<div className="space-y-4 pb-8">
|
||||
{wizardStep !== 'select' && renderStep()}
|
||||
<div className="space-y-4 pb-4">
|
||||
{wizardStep !== 'select' && (
|
||||
<>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="rounded-full border border-indigo-500/40 bg-indigo-500/10 px-3 py-1 text-[11px] font-medium text-indigo-200">
|
||||
当前步骤:{WIZARD_STEPS.find((s) => s.id === wizardStep)?.label || wizardStep}
|
||||
</span>
|
||||
</div>
|
||||
{renderStep()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{repairTask && (
|
||||
<div className="rounded-2xl border border-slate-800/90 bg-slate-950/80 p-4">
|
||||
<RepairTaskPanel repairTask={repairTask} repairLogs={repairLogs} executionState={executionState} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,21 +222,25 @@ function WizardProgressBar({ currentStep, onSelectStep }) {
|
||||
onClick={() => onSelectStep(step.id)}
|
||||
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
isCurrent
|
||||
? 'bg-indigo-500/20 border border-indigo-400/50 text-indigo-200'
|
||||
? 'bg-indigo-500/25 border-2 border-indigo-400/70 text-indigo-100 font-semibold shadow-[0_0_12px_rgba(99,102,241,0.2)]'
|
||||
: isCompleted
|
||||
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-200'
|
||||
: 'border border-slate-700 text-slate-500 hover:border-slate-600'
|
||||
? 'bg-emerald-500/12 border border-emerald-500/40 text-emerald-200'
|
||||
: 'border border-slate-700/70 text-slate-500 hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="h-3 w-3" />
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full text-[10px]">{index + 1}</span>
|
||||
<span className={`flex h-4 w-4 items-center justify-center rounded-full text-[10px] ${
|
||||
isCurrent ? 'bg-indigo-400/20' : ''
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
{step.label}
|
||||
</button>
|
||||
{index < WIZARD_STEPS.length - 1 && (
|
||||
<div className={`h-px w-4 ${isCompleted ? 'bg-emerald-500/30' : 'bg-slate-700'}`} />
|
||||
<div className={`h-px w-4 ${isCompleted ? 'bg-emerald-500/40' : isCurrent ? 'bg-indigo-500/30' : 'bg-slate-700'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
buildMatchRunExplanation,
|
||||
candidateSignature,
|
||||
deriveRepairOutcome,
|
||||
providerModeLabel
|
||||
} from '../../utils/exceptions';
|
||||
|
||||
const OUTCOME_COLORS = {
|
||||
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',
|
||||
success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200',
|
||||
partial: 'border-rose-500/30 bg-rose-500/10 text-rose-200',
|
||||
failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200',
|
||||
};
|
||||
|
||||
const OUTCOME_ICONS = {
|
||||
submitting: '⟳',
|
||||
accepted: '◎',
|
||||
running: '⟳',
|
||||
success: '✓',
|
||||
partial: '⚠',
|
||||
failed: '✗'
|
||||
};
|
||||
|
||||
export default function MatchRunFeedback({ executionState, repairTask, repairLogs, detail }) {
|
||||
if (!executionState?.status) return null;
|
||||
|
||||
const belongsToCurrent = repairTask?.task_id === executionState.repairTaskId;
|
||||
const outcome = belongsToCurrent ? (deriveRepairOutcome(repairTask) || executionState.status) : executionState.status;
|
||||
const outcomeClass = OUTCOME_COLORS[outcome] || 'border-slate-700 text-slate-400';
|
||||
|
||||
const execute = repairTask?.stats?.execute || {};
|
||||
const succeeded = execute.succeeded_items;
|
||||
const failed = execute.failed_items;
|
||||
const candidates = detail?.match_candidates_json || [];
|
||||
const best = candidates[0] || null;
|
||||
const bestProvider = best ? (best.provider || best.source || '') : '';
|
||||
const afterSig = candidateSignature(candidates);
|
||||
const beforeSig = executionState.beforeCandidateSignature || '';
|
||||
const explanation = buildMatchRunExplanation({ executionState, repairTask, detail, beforeSig, afterSig });
|
||||
|
||||
let conclusionTone = 'text-slate-400';
|
||||
if (executionState.status === 'submitting' || executionState.status === 'accepted' || executionState.status === 'running') {
|
||||
conclusionTone = 'text-indigo-300';
|
||||
} else if (outcome === 'failed') {
|
||||
conclusionTone = 'text-rose-300';
|
||||
} else if (outcome === 'partial' || (executionState.status === 'completed' && beforeSig && beforeSig === afterSig)) {
|
||||
conclusionTone = 'text-amber-300';
|
||||
} else if (outcome === 'success') {
|
||||
conclusionTone = 'text-cyan-300';
|
||||
}
|
||||
|
||||
const errorLogs = repairLogs?.filter((l) => l.level === 'error') || [];
|
||||
const statusText = executionState.status === 'submitting' ? '提交中'
|
||||
: executionState.status === 'accepted' ? '等待执行'
|
||||
: executionState.status === 'running' ? '执行中'
|
||||
: outcome === 'success' ? '完成'
|
||||
: outcome === 'partial' ? '部分失败'
|
||||
: outcome === 'failed' ? '失败'
|
||||
: executionState.status || '';
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-200">匹配反馈</span>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] ${outcomeClass}`}>
|
||||
{OUTCOME_ICONS[outcome] || ''} {statusText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{executionState.requestedProviderMode && (
|
||||
<div className="mt-2 text-[11px] text-slate-400">
|
||||
本次提交:{providerModeLabel(executionState.requestedProviderMode)}
|
||||
{executionState.submittedParams?.providers?.length > 0
|
||||
? ` · providers: ${executionState.submittedParams.providers.join(',')}`
|
||||
: ''}
|
||||
{executionState.repairTaskId
|
||||
? ` · 任务号 ${executionState.repairTaskId.slice(0, 8)}...`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(executionState.status === 'completed' || executionState.status === 'failed') && (
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||
{succeeded != null && <span>成功 <span className="text-emerald-400/80">{succeeded}</span></span>}
|
||||
{failed != null && <span>失败 <span className="text-rose-400/80">{failed}</span></span>}
|
||||
<span>候选 <span className="text-slate-300">{candidates.length}</span></span>
|
||||
{best && (
|
||||
<span>
|
||||
最高 <span className="text-slate-300">{providerModeLabel(bestProvider)}</span>
|
||||
{' '}<span className="text-slate-300">{Number(best.score).toFixed(1)}%</span>
|
||||
</span>
|
||||
)}
|
||||
{detail?.match_source && <span>来源 <span className="text-slate-300">{detail.match_source}</span></span>}
|
||||
{detail?.match_status && <span>状态 <span className="text-slate-300">{detail.match_status}</span></span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explanation && (
|
||||
<div className={`mt-2 text-[11px] leading-relaxed ${conclusionTone}`}>
|
||||
{explanation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorLogs.length > 0 && (
|
||||
<div className="mt-2 rounded-xl border border-rose-900/30 bg-rose-950/10 p-2">
|
||||
<div className="text-[10px] font-medium text-rose-300/80">匹配源错误</div>
|
||||
{errorLogs.slice(0, 3).map((log, i) => (
|
||||
<div key={i} className="mt-1 text-[10px] text-rose-200/60">{log.message}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// frontend/src/components/exceptions/RepairTaskPanel.jsx
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { LoaderCircle, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { isTerminalRepairStatus } from '../../utils/exceptions';
|
||||
|
||||
export default function RepairTaskPanel({ repairTask, repairLogs, executionState }) {
|
||||
export default function RepairTaskPanel({ repairTask, repairLogs, executionState, variant = 'default' }) {
|
||||
if (!repairTask && !executionState) return null;
|
||||
|
||||
const statusLabel = {
|
||||
@@ -18,22 +18,70 @@ export default function RepairTaskPanel({ repairTask, repairLogs, executionState
|
||||
failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200'
|
||||
};
|
||||
|
||||
const isTerminal = repairTask && isTerminalRepairStatus(repairTask.status);
|
||||
|
||||
// 紧凑模式:一行状态条
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border border-slate-800 bg-slate-950/60 px-3 py-2">
|
||||
<div className="flex items-center gap-2 truncate text-xs">
|
||||
{isTerminal && repairTask.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
) : isTerminal && repairTask.status === 'failed' ? (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0 text-rose-400" />
|
||||
) : (
|
||||
<LoaderCircle className={`h-3.5 w-3.5 shrink-0 text-indigo-300 ${repairTask && !isTerminal ? 'animate-spin' : ''}`} />
|
||||
)}
|
||||
<span className="shrink-0 font-medium text-slate-200">修复任务</span>
|
||||
{repairTask && (
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] ${statusColor[repairTask.status] || ''}`}>
|
||||
{statusLabel[repairTask.status] || repairTask.status}
|
||||
</span>
|
||||
)}
|
||||
{repairTask && (
|
||||
<span className="truncate font-mono text-slate-500" title={repairTask.task_id}>
|
||||
{repairTask.task_id.slice(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
{repairTask?.error_message && (
|
||||
<span className="truncate text-rose-400">· {repairTask.error_message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<LoaderCircle className={`h-3 w-3 ${repairTask && !isTerminalRepairStatus(repairTask.status) ? 'animate-spin' : ''}`} />
|
||||
<span>修复任务</span>
|
||||
{repairTask && (
|
||||
<span className={`ml-auto rounded-full px-2 py-0.5 text-[11px] ${statusColor[repairTask.status] || ''}`}>
|
||||
{statusLabel[repairTask.status] || repairTask.status}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isTerminal && repairTask.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
) : isTerminal && repairTask.status === 'failed' ? (
|
||||
<XCircle className="h-4 w-4 text-rose-400" />
|
||||
) : (
|
||||
<LoaderCircle className={`h-4 w-4 text-indigo-300 ${repairTask && !isTerminal ? 'animate-spin' : ''}`} />
|
||||
)}
|
||||
<span className="text-sm font-medium text-slate-200">修复任务</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{repairTask && (
|
||||
<span className={`rounded-full border px-3 py-1 text-xs font-medium ${statusColor[repairTask.status] || ''}`}>
|
||||
{statusLabel[repairTask.status] || repairTask.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repairTask && (
|
||||
{isTerminal && (
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
任务号: {repairTask.task_id}
|
||||
当前为结果复核界面
|
||||
</div>
|
||||
)}
|
||||
{repairTask && (
|
||||
<div className="mt-1.5 text-[11px] text-slate-500">
|
||||
任务号: <span className="font-mono text-slate-400">{repairTask.task_id}</span>
|
||||
{repairTask.error_message && (
|
||||
<span className="ml-2 text-rose-400">{repairTask.error_message}</span>
|
||||
<span className="ml-2 text-rose-400">· {repairTask.error_message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// frontend/src/components/exceptions/steps/StepListen.jsx
|
||||
import { useState, useRef } from 'react';
|
||||
import { Headphones, Play, Pause, LoaderCircle } from 'lucide-react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Headphones, Play, Pause, LoaderCircle, ListMusic, Edit3, SkipForward, ChevronRight } from 'lucide-react';
|
||||
import { buildExceptionAudioUrl } from '../../../api/exceptions';
|
||||
import { formatSeconds } from '../../../utils/exceptions';
|
||||
import { formatSeconds, normalizeCandidateScore, formatCandidateScore, scoreToneClass, candidateProviderLabel } from '../../../utils/exceptions';
|
||||
|
||||
function InfoField({ label, value, mono = false }) {
|
||||
return (
|
||||
@@ -13,12 +13,41 @@ function InfoField({ label, value, mono = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function StepListen({ detailRecord, isLoading }) {
|
||||
export default function StepListen({ detailRecord, isLoading, onSetWizardStep }) {
|
||||
const audioRef = useRef(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [audioError, setAudioError] = useState('');
|
||||
const [showPlaybackHint, setShowPlaybackHint] = useState(false);
|
||||
|
||||
// 定义在 early return 之前,供 effect 和事件处理器使用
|
||||
const togglePlayInternal = useCallback(() => {
|
||||
if (!audioRef.current) return;
|
||||
if (audioRef.current.paused) {
|
||||
audioRef.current.play().catch(() => setAudioError('播放器启动失败'));
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handler = (e) => {
|
||||
const tag = e.target?.tagName?.toLowerCase();
|
||||
if (
|
||||
tag === 'input' || tag === 'textarea' || tag === 'select' ||
|
||||
tag === 'button' || tag === 'a' ||
|
||||
e.target?.isContentEditable
|
||||
) return;
|
||||
if (e.key === ' ' && e.target === document.body) {
|
||||
e.preventDefault();
|
||||
togglePlayInternal();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [togglePlayInternal]);
|
||||
|
||||
if (!detailRecord && !isLoading) {
|
||||
return (
|
||||
@@ -38,64 +67,177 @@ export default function StepListen({ detailRecord, isLoading }) {
|
||||
|
||||
const audioUrl = buildExceptionAudioUrl(detailRecord.exception_id);
|
||||
const ap = detailRecord.audio_props_json || {};
|
||||
const candidates = detailRecord.match_candidates_json || [];
|
||||
const exceptionType = detailRecord.exception_type;
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (audioRef.current.paused) {
|
||||
audioRef.current.play().catch(() => setAudioError('播放器启动失败'));
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
togglePlayInternal();
|
||||
setShowPlaybackHint(true);
|
||||
setTimeout(() => setShowPlaybackHint(false), 2000);
|
||||
};
|
||||
|
||||
// 宽屏时显示更多候选
|
||||
const maxCandidates = 4;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{detailRecord.display_title || '-'}</h3>
|
||||
<p className="mt-1 font-mono text-xs text-indigo-300/80">{detailRecord.filename}</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-300">
|
||||
<InfoField label="格式" value={ap.format || '--'} />
|
||||
<InfoField label="编码" value={ap.codec || '--'} />
|
||||
<InfoField label="采样率" value={ap.sample_rate ? `${ap.sample_rate} Hz` : '--'} />
|
||||
<InfoField label="比特率" value={ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'} />
|
||||
<InfoField label="位深" value={ap.bit_depth ? `${ap.bit_depth} bit` : '--'} />
|
||||
<InfoField label="时长" value={formatSeconds(ap.duration_seconds)} />
|
||||
<div className="space-y-4 xl:space-y-0 xl:grid xl:grid-cols-[minmax(0,1.05fr)_minmax(360px,0.8fr)] xl:gap-4">
|
||||
{/* 左列:歌曲信息 + 播放器 */}
|
||||
<div className="space-y-4">
|
||||
{/* 歌曲元数据卡 */}
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{detailRecord.display_title || '-'}</h3>
|
||||
<p className="mt-1 font-mono text-xs text-indigo-300/80">{detailRecord.filename}</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 xl:grid-cols-3 text-xs text-slate-300">
|
||||
<InfoField label="格式" value={ap.format || '--'} />
|
||||
<InfoField label="编码" value={ap.codec || '--'} />
|
||||
<InfoField label="采样率" value={ap.sample_rate ? `${ap.sample_rate} Hz` : '--'} />
|
||||
<InfoField label="比特率" value={ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'} />
|
||||
<InfoField label="位深" value={ap.bit_depth ? `${ap.bit_depth} bit` : '--'} />
|
||||
<InfoField label="时长" value={formatSeconds(ap.duration_seconds)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 试听预览 */}
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">试听预览</p>
|
||||
<h4 className="mt-1 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Headphones className="h-4 w-4 text-emerald-400" />
|
||||
试听确认
|
||||
{showPlaybackHint && (
|
||||
<span className={`ml-2 text-[11px] font-normal transition-opacity ${
|
||||
isPlaying ? 'text-emerald-300' : 'text-slate-400'
|
||||
}`}>
|
||||
{isPlaying ? '▸ 播放中' : '▸ 已暂停'}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-emerald-500/40 bg-emerald-500/10 text-emerald-200 transition hover:bg-emerald-500/20"
|
||||
title={isPlaying ? '暂停 (Space)' : '播放 (Space)'}
|
||||
>
|
||||
{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">
|
||||
<div className="relative">
|
||||
<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
|
||||
[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-emerald-400
|
||||
[&::-webkit-slider-thumb]:shadow-[0_0_6px_rgba(52,211,153,0.5)]
|
||||
[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full
|
||||
[&::-moz-range-thumb]:bg-emerald-400 [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between font-mono text-[11px] text-slate-500">
|
||||
<span>{formatSeconds(currentTime)}</span>
|
||||
<span className={`text-[10px] ${isPlaying ? 'text-emerald-400/70' : 'text-slate-600'}`}>
|
||||
{isPlaying ? '播放中' : '已暂停'}
|
||||
</span>
|
||||
<span>{formatSeconds(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{audioError ? (
|
||||
<p className="mt-3 text-xs text-rose-300">{audioError}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-[10px] text-slate-600">按 Space 键播放/暂停</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">试听预览</p>
|
||||
<h4 className="mt-2 flex items-center gap-2 text-sm font-medium text-white">
|
||||
<Headphones className="h-4 w-4 text-emerald-300" />在线试听
|
||||
{/* 右列:候选摘要 + 处理动作 */}
|
||||
<div className="space-y-4">
|
||||
{/* 候选摘要预览 */}
|
||||
{candidates.length > 0 && (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<h4 className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<ListMusic className="h-4 w-4 text-indigo-400" />
|
||||
候选摘要 · {candidates.length} 个匹配
|
||||
</h4>
|
||||
<div className="mt-3 space-y-2">
|
||||
{candidates.slice(0, maxCandidates).map((candidate, i) => (
|
||||
<div key={i} className={`rounded-xl border p-3 text-xs ${
|
||||
i === 0
|
||||
? 'border-indigo-400/30 bg-indigo-500/6'
|
||||
: 'border-slate-800/70 bg-slate-900/40'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate font-medium ${i === 0 ? 'text-indigo-100' : 'text-slate-200'}`}>
|
||||
{candidate.title || 'Unknown'}
|
||||
{i === 0 && <span className="ml-1.5 text-[10px] text-indigo-400">· 最佳</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-slate-400">
|
||||
{[candidate.artist, candidate.album].filter(Boolean).join(' · ') || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-slate-700 bg-slate-800/60 px-2 py-0.5 text-[9px] text-slate-300">
|
||||
{candidateProviderLabel(candidate)}
|
||||
</span>
|
||||
{normalizeCandidateScore(candidate.score) != null && (
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${scoreToneClass(candidate.score)}`}>
|
||||
{formatCandidateScore(candidate.score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidates.length === 0 && exceptionType !== 'missing_tags' && (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 p-4 text-center text-xs text-slate-500">
|
||||
<p>暂无候选匹配</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 处理动作区(改造 3) */}
|
||||
<div className="rounded-2xl border border-indigo-900/30 bg-indigo-950/15 p-4">
|
||||
<h4 className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<ChevronRight className="h-4 w-4 text-indigo-400" />
|
||||
处理动作
|
||||
</h4>
|
||||
<p className="mt-1 text-[11px] text-slate-500">选择下一步操作</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<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"
|
||||
onClick={() => onSetWizardStep('match')}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-indigo-500 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-indigo-500/20 transition hover:bg-indigo-400"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
|
||||
<ListMusic className="h-4 w-4" />
|
||||
查看候选
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSetWizardStep('edit')}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-slate-700 bg-slate-800/70 px-4 py-2.5 text-sm font-medium text-slate-200 transition hover:border-slate-600 hover:bg-slate-700"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
进入手动编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSetWizardStep('select')}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-2.5 text-sm font-medium text-slate-400 transition hover:border-slate-700 hover:text-slate-300"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
暂不处理 / 跳过
|
||||
</button>
|
||||
</div>
|
||||
<audio ref={audioRef} src={audioUrl} preload="metadata"
|
||||
onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)}
|
||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
|
||||
onError={() => setAudioError('音频文件不可用或已丢失')}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<input type="range" min="0" max={duration || 0} step="0.1"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={(e) => { const v = Number(e.target.value); setCurrentTime(v); if (audioRef.current) audioRef.current.currentTime = v; }}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-800 accent-emerald-400"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between font-mono text-[11px] text-slate-500">
|
||||
<span>{formatSeconds(currentTime)}</span><span>{formatSeconds(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{audioError ? <p className="mt-3 text-xs text-rose-300">{audioError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// frontend/src/components/exceptions/steps/StepMatch.jsx
|
||||
import { Search, LoaderCircle, ShieldAlert } from 'lucide-react';
|
||||
import { PROVIDER_MODES, chipClass, actionButtonClass, formatConfidence, providerLabel } from '../../../utils/exceptions';
|
||||
import { PROVIDER_MODES, chipClass, actionButtonClass, formatConfidence, normalizeCandidateScore, formatCandidateScore, scoreToneClass, candidateProviderLabel, scoreBreakdownItems, summarizeLowScoreReason } from '../../../utils/exceptions';
|
||||
import MatchRunFeedback from '../MatchRunFeedback';
|
||||
|
||||
function InfoField({ label, value }) {
|
||||
return (
|
||||
@@ -11,23 +12,11 @@ function InfoField({ 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 <span className={`rounded-full border px-2.5 py-1 text-xs ${map[status]}`}>{label}</span>;
|
||||
}
|
||||
|
||||
export default function StepMatch({
|
||||
detailRecord, isLoading,
|
||||
providerMode, setProviderMode, providers, setProviders,
|
||||
previewState, executionState,
|
||||
repairTask, repairLogs,
|
||||
onPreview, onExecute
|
||||
}) {
|
||||
if (!detailRecord && !isLoading) {
|
||||
@@ -66,33 +55,56 @@ export default function StepMatch({
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/75 p-4">
|
||||
<h4 className="text-sm font-medium text-white">现有匹配候选</h4>
|
||||
<div className="mt-3 space-y-2">
|
||||
{candidates.map((candidate, i) => (
|
||||
{candidates.map((candidate, i) => {
|
||||
const breakdown = scoreBreakdownItems(candidate.score_breakdown);
|
||||
const lowReason = summarizeLowScoreReason(candidate.score_breakdown);
|
||||
return (
|
||||
<div key={i} className={`rounded-xl border p-3 ${
|
||||
i === 0 ? 'border-indigo-400/30 bg-indigo-500/5' : 'border-slate-800 bg-slate-900/60'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-100">{candidate.title || 'Unknown'}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{/* 标题行:标题名 + 右侧 badge 组 */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-100">{candidate.title || 'Unknown'}</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">
|
||||
{[candidate.artist, candidate.album].filter(Boolean).join(' · ')}
|
||||
{candidate.year ? ` · ${candidate.year}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{candidate.score != null && (
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] font-medium ${
|
||||
candidate.score >= 0.8 ? 'bg-emerald-500/10 text-emerald-200' :
|
||||
candidate.score >= 0.5 ? 'bg-amber-500/10 text-amber-200' :
|
||||
'bg-rose-500/10 text-rose-200'
|
||||
}`}>
|
||||
{Math.round(candidate.score * 100)}%
|
||||
{/* badge 组:来源 · 最佳 · 分数 */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-slate-700 bg-slate-800/60 px-2 py-0.5 text-[10px] text-slate-300">
|
||||
{candidateProviderLabel(candidate)}
|
||||
</span>
|
||||
)}
|
||||
{i === 0 && (
|
||||
<span className="rounded-full border border-indigo-400/40 bg-indigo-500/10 px-2 py-0.5 text-[10px] text-indigo-200">
|
||||
最佳
|
||||
</span>
|
||||
)}
|
||||
{normalizeCandidateScore(candidate.score) != null && (
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${scoreToneClass(candidate.score)}`}>
|
||||
{formatCandidateScore(candidate.score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{candidate.source && (
|
||||
<div className="mt-2 text-[11px] text-slate-500">{providerLabel(candidate.source)}</div>
|
||||
{/* 低分原因摘要 */}
|
||||
{lowReason && (
|
||||
<div className="mt-2 text-[11px] leading-4 text-amber-300/80">{lowReason}</div>
|
||||
)}
|
||||
{/* 分数组成紧凑条 */}
|
||||
{breakdown.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-x-2.5 gap-y-1 text-[10px] text-slate-500">
|
||||
{breakdown.map((item) => (
|
||||
<span key={item.key} className="whitespace-nowrap">
|
||||
{item.label} <span className={item.max != null && item.value < item.max / 2 ? 'text-amber-400/70' : item.max === null && item.value > 0 ? 'text-rose-400/70' : ''}>{item.max == null && item.value > 0 ? `-${item.value}` : item.value}</span>{item.max != null ? `/${item.max}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -152,7 +164,12 @@ export default function StepMatch({
|
||||
)}
|
||||
|
||||
{executionState?.action === 'retry_match' && (
|
||||
<div className="mt-3"><StatusBadge status={executionState.status} /></div>
|
||||
<MatchRunFeedback
|
||||
executionState={executionState}
|
||||
repairTask={repairTask}
|
||||
repairLogs={repairLogs}
|
||||
detail={detailRecord}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// frontend/src/components/settings/AdvancedStrategySection.jsx
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
const STRATEGY_OPTIONS = [
|
||||
{
|
||||
key: 'metadataFallback',
|
||||
defaultVal: true,
|
||||
label: '开启多源元数据轮询回退',
|
||||
desc: '仅控制 Netease / QQ / Spotify 的身份兜底查询;AcoustID 与 MusicBrainz 主链路始终参与匹配。'
|
||||
},
|
||||
{
|
||||
key: 'downloadAssets',
|
||||
defaultVal: true,
|
||||
label: '自动下载并补全资产 (封面/歌词)',
|
||||
desc: '仅控制 Discogs / Last.fm / Genius 的增强信息查询与来源决策,本轮不会真实下载文件。'
|
||||
},
|
||||
{
|
||||
key: 'replaceLowQualityDuplicates',
|
||||
defaultVal: false,
|
||||
label: '重复项自动替换低音质 (慎用)',
|
||||
desc: '若发现重复曲目且新文件比特率更高,自动覆盖库中旧文件,而非移入回收站产生冲突。'
|
||||
}
|
||||
];
|
||||
|
||||
export default function AdvancedStrategySection({ advancedStrategy = {}, onUpdate }) {
|
||||
const getVal = (key, defaultVal) =>
|
||||
advancedStrategy[key] !== undefined ? advancedStrategy[key] : defaultVal;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
|
||||
<h3 className="mb-5 flex items-center border-b border-slate-800 pb-3 text-base font-semibold text-white">
|
||||
<SlidersHorizontal className="mr-2 h-5 w-5 text-purple-400" />
|
||||
高级运行策略 (Advanced Strategy)
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{STRATEGY_OPTIONS.map(({ key, defaultVal, label, desc }) => (
|
||||
<label key={key} className="group flex h-full cursor-pointer items-start space-x-3 rounded-lg border border-slate-800/50 bg-slate-950/50 p-4 transition-colors hover:border-blue-500/30 hover:bg-slate-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={getVal(key, defaultVal)}
|
||||
onChange={(e) => onUpdate('advancedStrategy', { ...advancedStrategy, [key]: e.target.checked })}
|
||||
className="mt-1 shrink-0 cursor-pointer rounded border-slate-700 bg-slate-950 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-white transition-colors group-hover:text-blue-400">{label}</span>
|
||||
<span className="mt-1.5 block text-xs leading-relaxed text-slate-500">{desc}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// frontend/src/components/settings/ConfigExportDialog.jsx
|
||||
import { X, Download, RefreshCw } from 'lucide-react';
|
||||
|
||||
export default function ConfigExportDialog({
|
||||
isOpen, isExporting, password, passwordConfirm,
|
||||
onClose, onExport, onPasswordChange, onPasswordConfirmChange
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
||||
<Download className="mr-2 h-5 w-5 text-emerald-400" />配置加密导出
|
||||
</h3>
|
||||
<button onClick={() => onClose(false)} disabled={isExporting} className="rounded p-1 text-slate-400 hover:text-white hover:bg-slate-800">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<p className="text-sm text-slate-400">导出当前系统配置为加密文件,导入时需要相同口令解密。</p>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-300">导出文件加密口令</label>
|
||||
<input type="password" className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-emerald-500 focus:outline-none"
|
||||
placeholder="输入加密口令(不少于 8 个字符)" value={password} onChange={(e) => onPasswordChange(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-300">再次确认口令</label>
|
||||
<input type="password" className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-emerald-500 focus:outline-none"
|
||||
placeholder="再次输入相同口令" value={passwordConfirm} onChange={(e) => onPasswordConfirmChange(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 border-t border-slate-800 px-6 py-4">
|
||||
<button onClick={() => onClose(false)} disabled={isExporting}
|
||||
className="rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 disabled:opacity-50">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={onExport} disabled={isExporting}
|
||||
className="flex items-center rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50">
|
||||
{isExporting ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{isExporting ? '导出中...' : '确认导出'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// frontend/src/components/settings/ConfigImportDialog.jsx
|
||||
import { X, Upload, RefreshCw, FileText, Clock } from 'lucide-react';
|
||||
import { formatBackupTimestamp } from '../../utils/schedule';
|
||||
|
||||
export default function ConfigImportDialog({
|
||||
importDialog, onClose, onDecrypt, onConfirm
|
||||
}) {
|
||||
if (!importDialog.isOpen) return null;
|
||||
const { stage, isSubmitting, fileName, exportedAt } = importDialog;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
||||
<Upload className="mr-2 h-5 w-5 text-blue-400" />
|
||||
{stage === 'password' ? '配置解密导入' : '确认导入配置'}
|
||||
</h3>
|
||||
<button onClick={() => onClose()} disabled={isSubmitting} className="rounded p-1 text-slate-400 hover:text-white hover:bg-slate-800">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stage === 'password' ? (
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-950 p-3">
|
||||
<FileText className="h-5 w-5 text-slate-400" />
|
||||
<div>
|
||||
<div className="text-sm text-slate-200">{fileName || '未知文件'}</div>
|
||||
{exportedAt && (
|
||||
<div className="flex items-center text-xs text-slate-500 mt-0.5">
|
||||
<Clock className="h-3 w-3 mr-1" />导出时间: {formatBackupTimestamp(exportedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-300">配置解密口令</label>
|
||||
<input type="password" className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="输入导出时设置的口令" value={importDialog.password}
|
||||
onChange={(e) => onDecrypt(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onDecrypt(importDialog.password)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-sm text-emerald-400 mb-4">解密成功!请确认导入以下配置(这将覆盖当前所有配置)。</p>
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-950 p-3 max-h-[200px] overflow-auto">
|
||||
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap">
|
||||
{JSON.stringify(importDialog.decryptedConfig, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-slate-800 px-6 py-4">
|
||||
<button onClick={() => onClose()} disabled={isSubmitting}
|
||||
className="rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 disabled:opacity-50">
|
||||
取消
|
||||
</button>
|
||||
{stage === 'password' ? (
|
||||
<button onClick={() => onDecrypt(importDialog.password)} disabled={isSubmitting || !importDialog.password.trim()}
|
||||
className="flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50">
|
||||
{isSubmitting ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{isSubmitting ? '解密中...' : '解密并预览'}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onConfirm} disabled={isSubmitting}
|
||||
className="flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50">
|
||||
{isSubmitting ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{isSubmitting ? '导入中...' : '确认导入并覆盖'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// frontend/src/components/settings/CorePathsSection.jsx
|
||||
import { Folder } from 'lucide-react';
|
||||
|
||||
export default function CorePathsSection({ input, output, trash, onUpdate }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
|
||||
<h3 className="mb-5 flex items-center border-b border-slate-800 pb-3 text-base font-semibold text-white">
|
||||
<Folder className="mr-2 h-5 w-5 text-blue-400" />
|
||||
基础核心目录
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-2 flex items-center text-sm font-medium text-white">输入目录 (待处理源)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-200 focus:border-blue-500 focus:outline-none"
|
||||
value={input}
|
||||
onChange={(e) => onUpdate('input', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 flex items-center text-sm font-medium text-white">输出目录 (Navidrome主库)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-200 focus:border-emerald-500 focus:outline-none"
|
||||
value={output}
|
||||
onChange={(e) => onUpdate('output', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 flex items-center text-sm font-medium text-white">回收站目录 (异常与重复隔离)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-sm font-mono text-slate-200 focus:border-rose-500 focus:outline-none"
|
||||
value={trash}
|
||||
onChange={(e) => onUpdate('trash', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// frontend/src/components/settings/MetadataServicesSection.jsx
|
||||
import { Database } from 'lucide-react';
|
||||
import ServiceStatusBadge from './ServiceStatusBadge';
|
||||
|
||||
export default function MetadataServicesSection({ metadata = {}, netStatus = {}, onUpdate }) {
|
||||
const updateMeta = (field, value) => {
|
||||
onUpdate('metadata', { ...metadata, [field]: value });
|
||||
};
|
||||
|
||||
const providerLabel = { acoustidUrl: 'AcoustID API 地址', acoustidClientKey: 'Client Key',
|
||||
musicbrainz: 'MusicBrainz API', netease: '网易云 API (Netease)', qq: 'QQ 音乐 API',
|
||||
spotifyUrl: 'Spotify API', spotifyClientId: 'Client ID', spotifySecret: 'Client Secret',
|
||||
discogsUrl: 'Discogs API', discogsToken: 'Token', lastfmUrl: 'Last.fm API', lastfmKey: 'Key',
|
||||
geniusUrl: 'Genius API', geniusToken: 'Token' };
|
||||
|
||||
const providers = [
|
||||
{ id: 'acoustid', fields: ['acoustidUrl', 'acoustidClientKey'] },
|
||||
{ id: 'musicbrainz', fields: ['musicbrainz'] },
|
||||
{ id: 'netease', fields: ['netease'] },
|
||||
{ id: 'qq', fields: ['qq'] },
|
||||
{ id: 'spotify', fields: ['spotifyUrl', 'spotifyClientId', 'spotifySecret'] },
|
||||
{ id: 'discogs', fields: ['discogsUrl', 'discogsToken'] },
|
||||
{ id: 'lastfm', fields: ['lastfmUrl', 'lastfmKey'] },
|
||||
{ id: 'genius', fields: ['geniusUrl', 'geniusToken'] }
|
||||
];
|
||||
|
||||
const status = netStatus.acoustid || {};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
|
||||
<h3 className="mb-5 flex items-center border-b border-slate-800 pb-3 text-base font-semibold text-white">
|
||||
<Database className="mr-2 h-5 w-5 text-rose-400" />
|
||||
元数据服务源配置
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{providers.map(({ id, fields }) => (
|
||||
<div key={id} className="rounded-lg border border-slate-800/50 bg-slate-950/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-300">{id.charAt(0).toUpperCase() + id.slice(1)}</span>
|
||||
<ServiceStatusBadge status={(netStatus[id] || {}).status} latencyMs={(netStatus[id] || {}).latencyMs} message={(netStatus[id] || {}).message} />
|
||||
</div>
|
||||
{fields.map((field) => (
|
||||
<input key={field} type={field.toLowerCase().includes('secret') || field.toLowerCase().includes('key') || field.toLowerCase().includes('token') ? 'password' : 'text'}
|
||||
className="mt-2 w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-300 focus:border-rose-500 focus:outline-none"
|
||||
placeholder={providerLabel[field] || field}
|
||||
value={metadata[field] || ''}
|
||||
onChange={(e) => updateMeta(field, e.target.value)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// frontend/src/components/settings/NotificationSection.jsx
|
||||
import { BellRing, MessageSquare, Send, Mail } from 'lucide-react';
|
||||
|
||||
export default function NotificationSection({ notifications = {}, onUpdate }) {
|
||||
const updateNested = (field, value) => {
|
||||
onUpdate('notifications', { ...notifications, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
|
||||
<h3 className="mb-5 flex items-center border-b border-slate-800 pb-3 text-base font-semibold text-white">
|
||||
<BellRing className="mr-2 h-5 w-5 text-sky-400" />
|
||||
消息推送通知 (Webhooks / Notifications)
|
||||
</h3>
|
||||
<p className="mb-6 text-xs text-slate-400">当自动入库批次任务完成,或产生大量异常记录时,系统将通过以下渠道向您发送详细报告。</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{/* DingTalk */}
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-slate-800/50 bg-slate-950/50 p-5">
|
||||
<h4 className="flex items-center text-sm font-semibold text-white">
|
||||
<MessageSquare className="mr-2 h-4 w-4 text-sky-500" />钉钉机器人 (DingTalk)
|
||||
</h4>
|
||||
<InputField label="Webhook 地址" type="text" placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
||||
value={notifications.dingtalkWebhook || ''} onChange={(v) => updateNested('dingtalkWebhook', v)} />
|
||||
<InputField label="加签密钥 (Secret - 可选)" type="password" placeholder="SEC..."
|
||||
value={notifications.dingtalkSecret || ''} onChange={(v) => updateNested('dingtalkSecret', v)} />
|
||||
</div>
|
||||
|
||||
{/* Telegram */}
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-slate-800/50 bg-slate-950/50 p-5">
|
||||
<h4 className="flex items-center text-sm font-semibold text-white">
|
||||
<Send className="mr-2 h-4 w-4 text-sky-500" />Telegram Bot
|
||||
</h4>
|
||||
<InputField label="Bot Token" type="password" placeholder="123456789:ABCdefGHIjkl..."
|
||||
value={notifications.telegramBotToken || ''} onChange={(v) => updateNested('telegramBotToken', v)} />
|
||||
<InputField label="Chat ID (接收者)" type="text" placeholder="例如: 12345678"
|
||||
value={notifications.telegramChatId || ''} onChange={(v) => updateNested('telegramChatId', v)} />
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-slate-800/50 bg-slate-950/50 p-5">
|
||||
<h4 className="flex items-center text-sm font-semibold text-white">
|
||||
<Mail className="mr-2 h-4 w-4 text-sky-500" />电子邮件 (Email)
|
||||
</h4>
|
||||
<div className="col-span-2">
|
||||
<InputField label="SMTP 服务器" type="text" placeholder="smtp.example.com:465"
|
||||
value={notifications.emailSmtp || ''} onChange={(v) => updateNested('emailSmtp', v)} />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<InputField label="发件账号" type="text" placeholder="bot@example.com"
|
||||
value={notifications.emailUser || ''} onChange={(v) => updateNested('emailUser', v)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<InputField label="授权密码" type="password" placeholder="********"
|
||||
value={notifications.emailPass || ''} onChange={(v) => updateNested('emailPass', v)} />
|
||||
</div>
|
||||
</div>
|
||||
<InputField label="目标收件人" type="text" placeholder="admin@example.com"
|
||||
value={notifications.emailTo || ''} onChange={(v) => updateNested('emailTo', v)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputField({ label, type, placeholder, value, onChange }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-slate-400">{label}</label>
|
||||
<input type={type} className="w-full rounded border border-slate-700 bg-slate-950 px-3 py-2 text-xs font-mono text-slate-200 focus:border-sky-500 focus:outline-none"
|
||||
placeholder={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// frontend/src/components/settings/ScheduleSection.jsx
|
||||
import { CalendarClock, Clock, PlayCircle } from 'lucide-react';
|
||||
import {
|
||||
SCHEDULE_TYPE, DEFAULT_CRON, DEFAULT_TIME,
|
||||
getScheduleSummary, buildDailyCron, buildWeeklyCron
|
||||
} from '../../utils/schedule';
|
||||
|
||||
export default function ScheduleSection({ schedule = {}, onUpdate }) {
|
||||
const enabled = schedule.enabled !== false;
|
||||
const type = schedule.type || SCHEDULE_TYPE.DAILY;
|
||||
const time = schedule.time || DEFAULT_TIME;
|
||||
const dayOfWeek = schedule.dayOfWeek || '1';
|
||||
const cron = schedule.cron || DEFAULT_CRON;
|
||||
|
||||
const setEnabled = (val) => onUpdate('schedule', { ...schedule, enabled: val });
|
||||
const setType = (val) => {
|
||||
if (val === SCHEDULE_TYPE.DAILY) {
|
||||
onUpdate('schedule', { ...schedule, type: val, cron: buildDailyCron(time) });
|
||||
} else if (val === SCHEDULE_TYPE.WEEKLY) {
|
||||
onUpdate('schedule', { ...schedule, type: val, dayOfWeek, cron: buildWeeklyCron(dayOfWeek, time) });
|
||||
} else {
|
||||
onUpdate('schedule', { ...schedule, type: val, cron: schedule.cron || DEFAULT_CRON });
|
||||
}
|
||||
};
|
||||
const setTime = (val) => {
|
||||
const newCron = type === SCHEDULE_TYPE.WEEKLY
|
||||
? buildWeeklyCron(dayOfWeek, val) : buildDailyCron(val);
|
||||
onUpdate('schedule', { ...schedule, type, time: val, cron: newCron });
|
||||
};
|
||||
const setDay = (val) => {
|
||||
onUpdate('schedule', { ...schedule, type, dayOfWeek: val, cron: buildWeeklyCron(val, time) });
|
||||
};
|
||||
const setCron = (val) => {
|
||||
onUpdate('schedule', { ...schedule, type: SCHEDULE_TYPE.CRON, cron: val });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-6 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-5 border-b border-slate-800 pb-3">
|
||||
<h3 className="text-base font-semibold text-white flex items-center">
|
||||
<CalendarClock className="w-5 h-5 mr-2 text-amber-400" />
|
||||
自动化定时任务
|
||||
</h3>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<div className="relative">
|
||||
<input type="checkbox" className="sr-only" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<div className={`block w-10 h-6 rounded-full transition-colors ${enabled ? 'bg-amber-500' : 'bg-slate-700'}`}></div>
|
||||
<div className={`dot absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${enabled ? 'transform translate-x-4' : ''}`}></div>
|
||||
</div>
|
||||
<span className="ml-3 text-sm font-medium text-slate-300">{enabled ? '已启用自动入库' : '已暂停自动入库'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col gap-4 transition-opacity ${enabled ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="w-44 shrink-0">
|
||||
<label className="flex items-center text-xs font-medium text-slate-400 mb-1.5">执行频率</label>
|
||||
<select className="w-full px-3 py-2 rounded bg-slate-950 border border-slate-700 text-slate-200 text-sm focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||||
value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value={SCHEDULE_TYPE.DAILY}>每天执行</option>
|
||||
<option value={SCHEDULE_TYPE.WEEKLY}>每周执行</option>
|
||||
<option value={SCHEDULE_TYPE.CRON}>专家模式 (Cron)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{type === SCHEDULE_TYPE.WEEKLY && (
|
||||
<div className="w-32 shrink-0">
|
||||
<label className="flex items-center text-xs font-medium text-slate-400 mb-1.5">星期</label>
|
||||
<select className="w-full px-3 py-2 rounded bg-slate-950 border border-slate-700 text-slate-200 text-sm focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||||
value={dayOfWeek} onChange={(e) => setDay(e.target.value)}>
|
||||
<option value="1">星期一</option><option value="2">星期二</option>
|
||||
<option value="3">星期三</option><option value="4">星期四</option>
|
||||
<option value="5">星期五</option><option value="6">星期六</option>
|
||||
<option value="0">星期日</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type !== SCHEDULE_TYPE.CRON && (
|
||||
<div className="w-32 shrink-0">
|
||||
<label className="flex items-center text-xs font-medium text-slate-400 mb-1.5">具体时间</label>
|
||||
<input type="time" className="w-full px-3 py-2 rounded bg-slate-950 border border-slate-700 text-slate-200 text-sm font-mono focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||||
style={{ colorScheme: 'dark' }} value={time} onChange={(e) => setTime(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === SCHEDULE_TYPE.CRON && (
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="flex items-center text-xs font-medium text-slate-400 mb-1.5">Cron 表达式</label>
|
||||
<input type="text" className="w-full px-3 py-2 rounded bg-slate-950 border border-slate-700 text-amber-400 text-sm font-mono focus:ring-amber-500 focus:border-amber-500 focus:outline-none"
|
||||
value={cron} onChange={(e) => setCron(e.target.value)} placeholder="例如: 0 2 * * *" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-slate-800/90 bg-[#0b1328] px-4 py-3.5">
|
||||
<div className="flex min-w-0 items-center text-sm">
|
||||
<Clock className="mr-2 h-4 w-4 shrink-0 text-amber-400" />
|
||||
<span className="mr-2 shrink-0 text-slate-200">解析结果:</span>
|
||||
<span className="truncate font-medium text-amber-400">{getScheduleSummary(schedule)}</span>
|
||||
</div>
|
||||
<button className="flex shrink-0 items-center justify-center rounded-lg border border-slate-600 bg-slate-700/70 px-4 py-2 text-sm font-medium text-slate-100 shadow-inner shadow-slate-950/30 transition hover:bg-slate-600/80">
|
||||
<PlayCircle className="mr-1.5 h-4 w-4" /> 立即运行测试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// frontend/src/components/settings/ServiceStatusBadge.jsx
|
||||
import { RefreshCw, Wifi, AlertCircle, XCircle } from 'lucide-react';
|
||||
|
||||
export default function ServiceStatusBadge({ status, latencyMs, message }) {
|
||||
if (status === 'checking') {
|
||||
return (
|
||||
<span className="flex items-center rounded border border-slate-700/50 bg-slate-800/50 px-2 py-0.5 text-xs text-slate-400">
|
||||
<RefreshCw className="mr-1.5 h-3 w-3 animate-spin" /> 探测中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'online') {
|
||||
return (
|
||||
<span className="flex items-center rounded border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-400">
|
||||
<Wifi className="mr-1.5 h-3 w-3" />
|
||||
{latencyMs ? `可达 (${latencyMs}ms)` : message}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return (
|
||||
<span className="flex items-center rounded border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-xs text-amber-400">
|
||||
<AlertCircle className="mr-1.5 h-3 w-3" />
|
||||
{latencyMs ? `高延迟 (${latencyMs}ms)` : message}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'offline') {
|
||||
return (
|
||||
<span className="flex items-center rounded border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-xs text-rose-400">
|
||||
<XCircle className="mr-1.5 h-3 w-3" /> {message}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center rounded border border-slate-700 bg-slate-800 px-2 py-0.5 text-xs text-slate-500">
|
||||
尚未检测
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -86,10 +86,12 @@ export default function useRepairTask() {
|
||||
});
|
||||
}, [repairTask]);
|
||||
|
||||
const registerExecution = useCallback((exceptionId, action, repairTaskId, previewPayload) => {
|
||||
const registerExecution = useCallback((exceptionId, action, repairTaskId, previewPayload, executionSnapshot = {}) => {
|
||||
setExecutionStateByExceptionId((prev) => ({
|
||||
...prev,
|
||||
[exceptionId]: {
|
||||
...prev[exceptionId],
|
||||
...executionSnapshot,
|
||||
exceptionId, action,
|
||||
status: 'accepted', repairTaskId,
|
||||
submittedAt: new Date().toISOString(),
|
||||
@@ -98,10 +100,11 @@ export default function useRepairTask() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setExecuting = useCallback((exceptionId, action, previewPayload) => {
|
||||
const setExecuting = useCallback((exceptionId, action, previewPayload, executionSnapshot = {}) => {
|
||||
setExecutionStateByExceptionId((prev) => ({
|
||||
...prev,
|
||||
[exceptionId]: {
|
||||
...executionSnapshot,
|
||||
exceptionId, action,
|
||||
status: 'submitting', repairTaskId: null,
|
||||
submittedAt: new Date().toISOString(),
|
||||
@@ -111,10 +114,12 @@ export default function useRepairTask() {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setExecutionFailed = useCallback((exceptionId, action, errorMessage, previewPayload) => {
|
||||
const setExecutionFailed = useCallback((exceptionId, action, errorMessage, previewPayload, executionSnapshot = {}) => {
|
||||
setExecutionStateByExceptionId((prev) => ({
|
||||
...prev,
|
||||
[exceptionId]: {
|
||||
...prev[exceptionId],
|
||||
...executionSnapshot,
|
||||
exceptionId, action,
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// frontend/src/hooks/useSettingsForm.js
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { fetchMetadataStatus, saveConfig } from '../api/config';
|
||||
import { deriveTaskState } from '../constants';
|
||||
import {
|
||||
buildConfigBackup, createBackupFilename, isBackupSupported,
|
||||
parseConfigBackup, restoreConfigFromBackup, triggerJsonDownload
|
||||
} from '../utils/configBackup';
|
||||
import { normalizeSchedule } from '../utils/schedule';
|
||||
|
||||
const METADATA_SERVICE_KEYS = [
|
||||
'acoustid', 'musicbrainz', 'netease', 'qq',
|
||||
'spotify', 'discogs', 'lastfm', 'genius'
|
||||
];
|
||||
|
||||
function createIdleNetStatus() {
|
||||
return METADATA_SERVICE_KEYS.reduce((acc, key) => {
|
||||
acc[key] = { status: 'idle', latencyMs: null, message: '未检测' };
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function createClosedImportDialogState() {
|
||||
return {
|
||||
isOpen: false, stage: 'password', password: '',
|
||||
fileName: '', exportedAt: '', payload: null,
|
||||
decryptedConfig: null, isSubmitting: false
|
||||
};
|
||||
}
|
||||
|
||||
export default function useSettingsForm({ config, setConfig, setTaskState }) {
|
||||
const fileInputRef = useRef(null);
|
||||
const metadataStatusAbortRef = useRef(null);
|
||||
const netStatusRequestIdRef = useRef(0);
|
||||
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
...config,
|
||||
schedule: normalizeSchedule(config.schedule)
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportPassword, setExportPassword] = useState('');
|
||||
const [exportPasswordConfirm, setExportPasswordConfirm] = useState('');
|
||||
const [importDialog, setImportDialog] = useState(createClosedImportDialogState);
|
||||
const [netStatus, setNetStatus] = useState(createIdleNetStatus);
|
||||
const resolvedNetStatusRef = useRef(createIdleNetStatus());
|
||||
const [toast, setToast] = useState(null);
|
||||
const toastTimeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfig({ ...config, schedule: normalizeSchedule(config.schedule) });
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => () => {
|
||||
metadataStatusAbortRef.current?.abort();
|
||||
if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current);
|
||||
}, []);
|
||||
|
||||
const showToast = (nextToast) => {
|
||||
if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current);
|
||||
setToast(nextToast);
|
||||
toastTimeoutRef.current = setTimeout(() => {
|
||||
setToast(null);
|
||||
toastTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const cancelMetadataStatusProbe = () => {
|
||||
metadataStatusAbortRef.current?.abort();
|
||||
metadataStatusAbortRef.current = null;
|
||||
};
|
||||
|
||||
const beginNetStatusRequest = () => {
|
||||
const requestId = netStatusRequestIdRef.current + 1;
|
||||
netStatusRequestIdRef.current = requestId;
|
||||
setNetStatus(METADATA_SERVICE_KEYS.reduce((acc, key) => {
|
||||
acc[key] = { status: 'checking', latencyMs: null, message: '探测中' };
|
||||
return acc;
|
||||
}, {}));
|
||||
return requestId;
|
||||
};
|
||||
|
||||
const isCurrentNetStatusRequest = (requestId) => netStatusRequestIdRef.current === requestId;
|
||||
|
||||
const commitNetStatus = (requestId, nextStatus) => {
|
||||
if (!isCurrentNetStatusRequest(requestId)) return false;
|
||||
resolvedNetStatusRef.current = nextStatus;
|
||||
setNetStatus(nextStatus);
|
||||
return true;
|
||||
};
|
||||
|
||||
const restoreNetStatus = (requestId) => {
|
||||
if (!isCurrentNetStatusRequest(requestId)) return false;
|
||||
setNetStatus(resolvedNetStatusRef.current);
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = beginNetStatusRequest();
|
||||
const abortController = new AbortController();
|
||||
metadataStatusAbortRef.current = abortController;
|
||||
|
||||
async function loadMetadataStatus() {
|
||||
try {
|
||||
const response = await fetchMetadataStatus({ signal: abortController.signal });
|
||||
if (metadataStatusAbortRef.current === abortController) {
|
||||
metadataStatusAbortRef.current = null;
|
||||
}
|
||||
commitNetStatus(requestId, response.metadataStatus);
|
||||
} catch (error) {
|
||||
if (metadataStatusAbortRef.current === abortController) {
|
||||
metadataStatusAbortRef.current = null;
|
||||
}
|
||||
if (error.name === 'AbortError') return;
|
||||
if (restoreNetStatus(requestId)) {
|
||||
showToast({ type: 'error', message: `API 连通性检测失败:${error.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMetadataStatus();
|
||||
|
||||
return () => {
|
||||
if (metadataStatusAbortRef.current === abortController) {
|
||||
abortController.abort();
|
||||
metadataStatusAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateField = useCallback((field, value) => {
|
||||
setLocalConfig((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const updateNestedField = useCallback((section, field, value) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
[section]: { ...prev[section], [field]: value }
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
cancelMetadataStatusProbe();
|
||||
const requestId = beginNetStatusRequest();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const response = await saveConfig(localConfig);
|
||||
setConfig(response.config);
|
||||
setTaskState(deriveTaskState(response.config));
|
||||
setLocalConfig({ ...response.config, schedule: normalizeSchedule(response.config.schedule) });
|
||||
commitNetStatus(requestId, response.metadataStatus);
|
||||
showToast({ type: 'success', message: '配置已保存,服务状态已刷新。' });
|
||||
} catch (error) {
|
||||
restoreNetStatus(requestId);
|
||||
showToast({ type: 'error', message: `保存失败:${error.message}` });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenExportDialog = () => {
|
||||
if (!isBackupSupported()) {
|
||||
showToast({ type: 'error', message: '当前浏览器环境不支持配置加密导出。' });
|
||||
return;
|
||||
}
|
||||
setExportPassword('');
|
||||
setExportPasswordConfirm('');
|
||||
setIsExportDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseExportDialog = (forceClose = false) => {
|
||||
if (!forceClose && isExporting) return;
|
||||
setIsExportDialogOpen(false);
|
||||
setExportPassword('');
|
||||
setExportPasswordConfirm('');
|
||||
};
|
||||
|
||||
const handleExportConfig = async () => {
|
||||
if (!exportPassword.trim()) {
|
||||
showToast({ type: 'error', message: '请输入导出口令。' });
|
||||
return;
|
||||
}
|
||||
if (exportPassword !== exportPasswordConfirm) {
|
||||
showToast({ type: 'error', message: '两次输入的导出口令不一致。' });
|
||||
return;
|
||||
}
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const backupPayload = await buildConfigBackup(localConfig, exportPassword);
|
||||
triggerJsonDownload(createBackupFilename(), backupPayload);
|
||||
handleCloseExportDialog(true);
|
||||
showToast({ type: 'success', message: '配置已导出,请妥善保管导出文件与口令。' });
|
||||
} catch (error) {
|
||||
showToast({ type: 'error', message: `导出失败:${error.message}` });
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportButtonClick = () => {
|
||||
if (!isBackupSupported()) {
|
||||
showToast({ type: 'error', message: '当前浏览器环境不支持配置解密导入。' });
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImportFileChange = async (event) => {
|
||||
const [file] = event.target.files || [];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const fileText = await file.text();
|
||||
const backupPayload = parseConfigBackup(fileText);
|
||||
setImportDialog({
|
||||
isOpen: true, stage: 'password', password: '',
|
||||
fileName: file.name, exportedAt: backupPayload.exportedAt || '',
|
||||
payload: backupPayload, decryptedConfig: null, isSubmitting: false
|
||||
});
|
||||
} catch (error) {
|
||||
showToast({ type: 'error', message: `导入失败:${error.message}` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseImportDialog = () => {
|
||||
if (importDialog.isSubmitting) return;
|
||||
setImportDialog(createClosedImportDialogState);
|
||||
};
|
||||
|
||||
const handleImportDecrypt = async () => {
|
||||
if (!importDialog.password.trim()) {
|
||||
showToast({ type: 'error', message: '请输入解密口令。' });
|
||||
return;
|
||||
}
|
||||
setImportDialog((cur) => ({ ...cur, isSubmitting: true }));
|
||||
try {
|
||||
const restoredConfig = await restoreConfigFromBackup(importDialog.payload, importDialog.password);
|
||||
setImportDialog((cur) => ({
|
||||
...cur, stage: 'confirm', decryptedConfig: restoredConfig, isSubmitting: false
|
||||
}));
|
||||
} catch (error) {
|
||||
setImportDialog((cur) => ({ ...cur, isSubmitting: false }));
|
||||
showToast({ type: 'error', message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportConfirm = async () => {
|
||||
if (!importDialog.decryptedConfig) {
|
||||
showToast({ type: 'error', message: '导入内容不存在,请重新选择配置文件。' });
|
||||
return;
|
||||
}
|
||||
cancelMetadataStatusProbe();
|
||||
const requestId = beginNetStatusRequest();
|
||||
setImportDialog((cur) => ({ ...cur, isSubmitting: true }));
|
||||
try {
|
||||
const response = await saveConfig(importDialog.decryptedConfig);
|
||||
setConfig(response.config);
|
||||
setTaskState(deriveTaskState(response.config));
|
||||
setLocalConfig({ ...response.config, schedule: normalizeSchedule(response.config.schedule) });
|
||||
commitNetStatus(requestId, response.metadataStatus);
|
||||
setImportDialog(createClosedImportDialogState);
|
||||
showToast({ type: 'success', message: '配置已导入并完成全量替换。' });
|
||||
} catch (error) {
|
||||
restoreNetStatus(requestId);
|
||||
setImportDialog((cur) => ({ ...cur, isSubmitting: false }));
|
||||
showToast({ type: 'error', message: `导入保存失败:${error.message}` });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
localConfig, netStatus, toast, isSaving,
|
||||
isExportDialogOpen, isExporting, exportPassword, exportPasswordConfirm,
|
||||
importDialog, fileInputRef,
|
||||
updateField, updateNestedField,
|
||||
handleSave,
|
||||
setExportPassword, setExportPasswordConfirm,
|
||||
handleOpenExportDialog, handleCloseExportDialog, handleExportConfig,
|
||||
handleImportButtonClick, handleImportFileChange,
|
||||
handleCloseImportDialog, handleImportDecrypt, handleImportConfirm
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
previewExceptionAction,
|
||||
executeExceptionAction,
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
import { fetchRepairTask, fetchRepairTaskLogs } from '../api/repairs';
|
||||
import {
|
||||
BULK_ACTIONS, normalizeActionParams,
|
||||
getMetadataQueueCounts, shouldRefreshExceptionListFully
|
||||
getMetadataQueueCounts, shouldRefreshExceptionListFully,
|
||||
isTerminalRepairStatus, candidateSignature
|
||||
} from '../utils/exceptions';
|
||||
import useExceptionSummary from '../hooks/useExceptionSummary';
|
||||
import useExceptionList from '../hooks/useExceptionList';
|
||||
@@ -62,6 +63,25 @@ export default function ExceptionPage() {
|
||||
const metadataQueueCounts = useMemo(() => getMetadataQueueCounts(metadataQueue), [metadataQueue]);
|
||||
const detailExecutionState = detailRecord ? executionStateByExceptionId[detailRecord.exception_id] || null : null;
|
||||
|
||||
// 监听 repairTask 到达终态后刷新 wizard 详情
|
||||
useEffect(() => {
|
||||
if (!repairTask?.task_id) return;
|
||||
if (!isTerminalRepairStatus(repairTask.status)) return;
|
||||
|
||||
const taskId = repairTask.task_id;
|
||||
if (completedRefreshRef.current.has(taskId)) return;
|
||||
completedRefreshRef.current.add(taskId);
|
||||
|
||||
refreshDetail();
|
||||
refreshMetadataQueue();
|
||||
refreshSummary();
|
||||
|
||||
if (shouldRefreshExceptionListFully(repairTask)) {
|
||||
refreshList();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [repairTask, refreshDetail, refreshMetadataQueue, refreshSummary, refreshList]);
|
||||
|
||||
const bulkState = useMemo(() => {
|
||||
if (!selectedIds.length) return { disabled: true, reason: '', actions: [] };
|
||||
const selectedItems = items.filter((item) => selectedIds.includes(item.exception_id));
|
||||
@@ -104,16 +124,21 @@ export default function ExceptionPage() {
|
||||
if (!exceptionIds.length || !action) return;
|
||||
setPreviewState({ loading: true, payload: null, error: '', action });
|
||||
setExecuteError('');
|
||||
const params = normalizeActionParams(action, {
|
||||
...actionParams,
|
||||
provider_mode: providerMode,
|
||||
providers
|
||||
});
|
||||
try {
|
||||
const payload = await previewExceptionAction({
|
||||
exception_ids: exceptionIds, action,
|
||||
params: normalizeActionParams(action, actionParams)
|
||||
params
|
||||
});
|
||||
setPreviewState({ loading: false, payload, error: '', action });
|
||||
} catch (err) {
|
||||
setPreviewState({ loading: false, payload: null, error: err.message || '预览失败', action });
|
||||
}
|
||||
}, [selectedIds, detailRecord, actionParams, setPreviewState, setExecuteError]);
|
||||
}, [selectedIds, detailRecord, actionParams, providerMode, providers, setPreviewState, setExecuteError]);
|
||||
|
||||
const handleExecute = useCallback(async (action) => {
|
||||
const exceptionIds = selectedIds.length ? selectedIds : detailRecord ? [detailRecord.exception_id] : [];
|
||||
@@ -125,19 +150,30 @@ export default function ExceptionPage() {
|
||||
|
||||
const isSingleItem = exceptionIds.length === 1;
|
||||
const currentExceptionId = isSingleItem ? detailRecord?.exception_id : null;
|
||||
const params = normalizeActionParams(action, {
|
||||
...actionParams,
|
||||
provider_mode: providerMode,
|
||||
providers
|
||||
});
|
||||
const executionSnapshot = action === 'retry_match' ? {
|
||||
requestedProviderMode: providerMode,
|
||||
requestedProviders: params.providers,
|
||||
submittedParams: params,
|
||||
beforeCandidateSignature: candidateSignature(detailRecord?.match_candidates_json || [])
|
||||
} : {};
|
||||
setExecuteError('');
|
||||
|
||||
if (currentExceptionId) {
|
||||
setExecuting(currentExceptionId, action, previewState.payload);
|
||||
setExecuting(currentExceptionId, action, previewState.payload, executionSnapshot);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await executeExceptionAction({
|
||||
exception_ids: exceptionIds, action,
|
||||
params: normalizeActionParams(action, actionParams)
|
||||
params
|
||||
});
|
||||
if (currentExceptionId) {
|
||||
registerExecution(currentExceptionId, action, payload.repair_task_id, previewState.payload);
|
||||
registerExecution(currentExceptionId, action, payload.repair_task_id, previewState.payload, executionSnapshot);
|
||||
}
|
||||
const tp = await fetchRepairTask(payload.repair_task_id);
|
||||
const lp = await fetchRepairTaskLogs(payload.repair_task_id, 1, 20);
|
||||
@@ -145,11 +181,11 @@ export default function ExceptionPage() {
|
||||
setRepairLogs(lp.logs);
|
||||
} catch (err) {
|
||||
if (currentExceptionId) {
|
||||
setExecutionFailed(currentExceptionId, action, err.message || '执行失败', previewState.payload);
|
||||
setExecutionFailed(currentExceptionId, action, err.message || '执行失败', previewState.payload, executionSnapshot);
|
||||
}
|
||||
setExecuteError(err.message || '执行失败');
|
||||
}
|
||||
}, [selectedIds, detailRecord, actionParams, previewState, setExecuting, registerExecution, setExecutionFailed, setRepairTask, setRepairLogs, setExecuteError]);
|
||||
}, [selectedIds, detailRecord, actionParams, providerMode, providers, previewState, setExecuting, registerExecution, setExecutionFailed, setRepairTask, setRepairLogs, setExecuteError]);
|
||||
|
||||
const handleUpdateMetadata = useCallback((key, value) => {
|
||||
setActionParams((prev) => ({
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function LibraryPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full py-6">
|
||||
<div className="relative flex h-full min-h-0 flex-col py-6">
|
||||
<div className="mb-8 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="mb-2 text-2xl font-bold text-white">音乐库总览</h2>
|
||||
@@ -231,7 +231,7 @@ export default function LibraryPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-lg">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-lg">
|
||||
<div className="border-b border-slate-800 bg-slate-900/70 px-5 py-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
@@ -292,7 +292,7 @@ export default function LibraryPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
@@ -363,6 +363,7 @@ export default function LibraryPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
@@ -391,6 +392,7 @@ export default function LibraryPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{moveSuccess ? (
|
||||
<div className="fixed bottom-6 right-6 z-30 max-w-sm rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100 shadow-2xl">
|
||||
|
||||
+88
-1448
File diff suppressed because it is too large
Load Diff
@@ -105,9 +105,113 @@ export function isTerminalRepairStatus(status) {
|
||||
return status === 'completed' || status === 'failed';
|
||||
}
|
||||
|
||||
export function candidateSignature(candidates = []) {
|
||||
return candidates
|
||||
.map((c) => [
|
||||
c.provider || c.source || '',
|
||||
c.title || '',
|
||||
c.artist || '',
|
||||
c.album || '',
|
||||
Number(c.score ?? 0).toFixed(1)
|
||||
].join('|'))
|
||||
.join('||');
|
||||
}
|
||||
|
||||
export function providerModeLabel(modeId) {
|
||||
const mode = PROVIDER_MODES.find((m) => m.id === modeId);
|
||||
return mode ? mode.label : modeId || '多源并行';
|
||||
}
|
||||
|
||||
export function deriveRepairOutcome(repairTask) {
|
||||
if (!repairTask) return null;
|
||||
const execute = repairTask.stats?.execute || {};
|
||||
if (repairTask.status === 'failed') return 'failed';
|
||||
if ((execute.failed_items || 0) > 0 && (execute.succeeded_items || 0) === 0) return 'failed';
|
||||
if ((execute.failed_items || 0) > 0) return 'partial';
|
||||
if (repairTask.status === 'completed') return 'success';
|
||||
return repairTask.status || 'accepted';
|
||||
}
|
||||
|
||||
export function buildMatchRunExplanation({ executionState, repairTask, detail, beforeSig, afterSig }) {
|
||||
if (!executionState || !executionState.status) return '';
|
||||
if (executionState.status === 'submitting') return '正在提交匹配请求...';
|
||||
if (executionState.status === 'accepted') return '匹配请求已提交,等待后端执行。';
|
||||
if (executionState.status === 'running') return '后端正在执行匹配任务...';
|
||||
|
||||
const outcome = deriveRepairOutcome(repairTask);
|
||||
if (outcome === 'failed') return repairTask?.error_message || executionState.error || '匹配执行失败,候选未更新。';
|
||||
|
||||
const execute = repairTask?.stats?.execute || {};
|
||||
const succeeded = execute.succeeded_items || 0;
|
||||
const failed = execute.failed_items || 0;
|
||||
|
||||
if (outcome === 'partial') {
|
||||
const parts = [`匹配完成,${succeeded} 项成功,${failed} 项失败。`];
|
||||
if (detail?.match_message) parts.push(detail.match_message);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
const reqMode = executionState.requestedProviderMode;
|
||||
const expectedProviders = PROVIDER_MODES.find((m) => m.id === reqMode)?.providers || [];
|
||||
const submittedProviders = executionState.submittedParams?.providers || [];
|
||||
const providerMismatch = JSON.stringify(submittedProviders) !== JSON.stringify(expectedProviders);
|
||||
|
||||
const lastRepairId = detail?.last_repair_task_id;
|
||||
const thisRepairId = executionState.repairTaskId;
|
||||
const notRefreshed = lastRepairId && thisRepairId && String(lastRepairId) !== String(thisRepairId);
|
||||
if (notRefreshed) return '当前详情尚未刷新到本次任务结果,正在重新拉取。';
|
||||
|
||||
const candidates = detail?.match_candidates_json || [];
|
||||
const unchanged = beforeSig === afterSig;
|
||||
const best = candidates[0] || null;
|
||||
const bestProvider = best ? (best.provider || best.source || '') : '';
|
||||
const bestScore = best?.score != null ? Number(best.score).toFixed(1) : '--';
|
||||
const bestTitle = best?.title || '';
|
||||
const bestMatchesExpected = expectedProviders.length === 0 || expectedProviders.includes(bestProvider);
|
||||
const parts = [];
|
||||
|
||||
if (candidates.length === 0) {
|
||||
parts.push('本次匹配完成,但没有找到候选。');
|
||||
if (detail?.match_message) parts.push(detail.match_message);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
if (providerMismatch) {
|
||||
parts.push(`选择和提交参数不一致:界面选择 ${providerModeLabel(reqMode)},但后端收到 ${submittedProviders.join(',') || '多源并行'}。`);
|
||||
}
|
||||
|
||||
if (unchanged && candidates.length > 0) {
|
||||
parts.push('匹配完成,但候选列表与执行前一致,没有发现更优结果。');
|
||||
if (best) parts.push(`最高候选仍是 ${providerModeLabel(bestProvider)} · ${bestTitle} · ${bestScore} 分。`);
|
||||
}
|
||||
|
||||
if (!unchanged && !bestMatchesExpected && expectedProviders.length > 0) {
|
||||
parts.push(`本次按 ${providerModeLabel(reqMode)} 执行,但最高候选来自 ${providerModeLabel(bestProvider)}。`);
|
||||
}
|
||||
|
||||
if (!unchanged && best && Number(bestScore) < 80) {
|
||||
parts.push('找到候选,但分数不足或差距不够,仍需人工复核。');
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push('匹配完成,候选已更新。');
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
parts.push(`${failed} 个匹配源执行失败。`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export function normalizeActionParams(action, params) {
|
||||
if (action === 'retry_match') {
|
||||
return { provider_mode: params.provider_mode || 'all', providers: params.providers || [] };
|
||||
const providerMode = params.provider_mode || 'all';
|
||||
const selectedMode = PROVIDER_MODES.find((mode) => mode.id === providerMode);
|
||||
return {
|
||||
provider_mode: providerMode,
|
||||
providers: params.providers ?? selectedMode?.providers ?? []
|
||||
};
|
||||
}
|
||||
if (action === 'save_and_organize' || action === 'edit_metadata') {
|
||||
return { metadata_patch: { ...(params.metadata_patch || {}) } };
|
||||
@@ -229,3 +333,111 @@ export function shouldRefreshExceptionListFully(repairTask) {
|
||||
if (itemCount !== 1) return true;
|
||||
return !['retry_match', 'select_match_candidate', 'edit_metadata'].includes(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化候选分数到百分制(0-100 范围)。
|
||||
* 如果 score <= 1,视为概率分数(0-1),乘以 100;
|
||||
* 如果 score > 1,视为已是百分制(0-100),直接返回。
|
||||
* 无效值返回 null。
|
||||
*/
|
||||
export function normalizeCandidateScore(score) {
|
||||
const value = Number(score);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
if (value <= 1) return value * 100;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化候选分数为带 "%" 的字符串,保留 1 位小数。
|
||||
* 无效值返回空字符串。
|
||||
*/
|
||||
export function formatCandidateScore(score) {
|
||||
const normalized = normalizeCandidateScore(score);
|
||||
if (normalized == null) return '';
|
||||
return `${normalized.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据归一化后的百分制分数返回 Tailwind 色调类名。
|
||||
* >= 80 → emerald(绿),>= 50 → amber(琥珀),否则 → rose(玫红)。
|
||||
* 无效值返回空字符串。
|
||||
*/
|
||||
export function scoreToneClass(score) {
|
||||
const normalized = normalizeCandidateScore(score);
|
||||
if (normalized == null) return '';
|
||||
if (normalized >= 80) return 'bg-emerald-500/10 text-emerald-200';
|
||||
if (normalized >= 50) return 'bg-amber-500/10 text-amber-200';
|
||||
return 'bg-rose-500/10 text-rose-200';
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回候选的来源提供者标签。
|
||||
* 优先 candidate.provider,回退 candidate.source,再回退显示"推荐候选"。
|
||||
*/
|
||||
export function candidateProviderLabel(candidate) {
|
||||
return providerLabel(candidate?.provider || candidate?.source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定顺序的分数组成项,每项包含 key/label/value/max。
|
||||
* 无效值按 0 处理。version_penalty 作为扣分项(max = null)。
|
||||
*/
|
||||
export function scoreBreakdownItems(scoreBreakdown) {
|
||||
if (!scoreBreakdown || typeof scoreBreakdown !== 'object') return [];
|
||||
const spec = [
|
||||
{ key: 'fingerprint', label: '指纹/搜索', max: 30 },
|
||||
{ key: 'title', label: '标题', max: 20 },
|
||||
{ key: 'artist', label: '艺人', max: 15 },
|
||||
{ key: 'album', label: '专辑', max: 10 },
|
||||
{ key: 'duration', label: '时长', max: 10 },
|
||||
{ key: 'track_disc', label: '曲序', max: 5 },
|
||||
{ key: 'album_context', label: '专辑上下文', max: 10 },
|
||||
{ key: 'version_penalty', label: '版本扣分', max: null },
|
||||
];
|
||||
return spec.map(({ key, label, max }) => {
|
||||
const raw = scoreBreakdown[key];
|
||||
const value = Number.isFinite(Number(raw)) ? Number(raw) : 0;
|
||||
return { key, label, value, max };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 score_breakdown 生成一句低分原因摘要。
|
||||
* 没有 breakdown 或没有明显低分项时返回空字符串。
|
||||
*/
|
||||
export function summarizeLowScoreReason(scoreBreakdown) {
|
||||
if (!scoreBreakdown || typeof scoreBreakdown !== 'object') return '';
|
||||
|
||||
const rules = [
|
||||
{ key: 'fingerprint', label: '指纹', max: 30, half: 15 },
|
||||
{ key: 'title', label: '标题', max: 20, half: 10 },
|
||||
{ key: 'artist', label: '艺人', max: 15, half: 7.5 },
|
||||
{ key: 'album', label: '专辑', max: 10, half: 5 },
|
||||
{ key: 'duration', label: '时长', max: 10, half: 5 },
|
||||
{ key: 'track_disc', label: '曲序', max: 5, half: 2.5 },
|
||||
{ key: 'album_context', label: '专辑上下文', max: 10, half: 5 },
|
||||
];
|
||||
|
||||
const lowItems = rules
|
||||
.filter(({ key, half, max: maxVal }) => {
|
||||
const raw = scoreBreakdown[key];
|
||||
const value = Number.isFinite(Number(raw)) ? Number(raw) : 0;
|
||||
return maxVal != null && value < half;
|
||||
})
|
||||
.map((r) => r.label);
|
||||
|
||||
const hasPenalty = (() => {
|
||||
const raw = scoreBreakdown.version_penalty;
|
||||
const value = Number.isFinite(Number(raw)) ? Number(raw) : 0;
|
||||
return value > 0;
|
||||
})();
|
||||
|
||||
const parts = [];
|
||||
if (lowItems.length > 0) {
|
||||
parts.push(`低分原因:${lowItems.join('、')}匹配不足`);
|
||||
}
|
||||
if (hasPenalty) {
|
||||
parts.push('版本扣分');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// frontend/src/utils/schedule.js
|
||||
|
||||
export const DEFAULT_CRON = '0 2 * * *';
|
||||
export const DEFAULT_TIME = '02:00';
|
||||
|
||||
export const SCHEDULE_TYPE = {
|
||||
DAILY: 'daily',
|
||||
WEEKLY: 'weekly',
|
||||
CRON: 'cron'
|
||||
};
|
||||
|
||||
export const WEEKDAY_OPTIONS = [
|
||||
{ value: '1', label: '周一' },
|
||||
{ value: '2', label: '周二' },
|
||||
{ value: '3', label: '周三' },
|
||||
{ value: '4', label: '周四' },
|
||||
{ value: '5', label: '周五' },
|
||||
{ value: '6', label: '周六' },
|
||||
{ value: '0', label: '周日' }
|
||||
];
|
||||
|
||||
export function padCronSegment(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function isCronNumber(value, min, max) {
|
||||
if (!/^\d+$/.test(value)) return false;
|
||||
const numericValue = Number(value);
|
||||
return numericValue >= min && numericValue <= max;
|
||||
}
|
||||
|
||||
export function formatTimeFromCron(hour, minute) {
|
||||
return `${padCronSegment(hour)}:${padCronSegment(minute)}`;
|
||||
}
|
||||
|
||||
export function buildDailyCron(time) {
|
||||
const [hour, minute] = time.split(':');
|
||||
return `${Number(minute)} ${Number(hour)} * * *`;
|
||||
}
|
||||
|
||||
export function buildWeeklyCron(day, time) {
|
||||
const [hour, minute] = time.split(':');
|
||||
return `${Number(minute)} ${Number(hour)} * * ${day}`;
|
||||
}
|
||||
|
||||
export function normalizeSchedule(schedule) {
|
||||
const nextSchedule = {
|
||||
enabled: true,
|
||||
type: SCHEDULE_TYPE.DAILY,
|
||||
dayOfWeek: '1',
|
||||
time: DEFAULT_TIME,
|
||||
cron: DEFAULT_CRON,
|
||||
...schedule
|
||||
};
|
||||
const normalizedCron =
|
||||
typeof nextSchedule.cron === 'string' && nextSchedule.cron.trim()
|
||||
? nextSchedule.cron.trim()
|
||||
: DEFAULT_CRON;
|
||||
const parts = normalizedCron.split(/\s+/);
|
||||
|
||||
if (parts.length === 5) {
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
const hasSimpleTime = isCronNumber(minute, 0, 59) && isCronNumber(hour, 0, 23);
|
||||
|
||||
if (hasSimpleTime) {
|
||||
nextSchedule.time = nextSchedule.time || formatTimeFromCron(hour, minute);
|
||||
}
|
||||
|
||||
if (
|
||||
!schedule?.type &&
|
||||
hasSimpleTime &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
nextSchedule.type = SCHEDULE_TYPE.DAILY;
|
||||
nextSchedule.time = formatTimeFromCron(hour, minute);
|
||||
} else if (
|
||||
!schedule?.type &&
|
||||
hasSimpleTime &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
['0', '1', '2', '3', '4', '5', '6', '7'].includes(dayOfWeek)
|
||||
) {
|
||||
nextSchedule.type = SCHEDULE_TYPE.WEEKLY;
|
||||
nextSchedule.dayOfWeek = dayOfWeek === '7' ? '0' : dayOfWeek;
|
||||
nextSchedule.time = formatTimeFromCron(hour, minute);
|
||||
} else if (!schedule?.type) {
|
||||
nextSchedule.type = SCHEDULE_TYPE.CRON;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextSchedule.time) nextSchedule.time = DEFAULT_TIME;
|
||||
if (!nextSchedule.dayOfWeek) nextSchedule.dayOfWeek = '1';
|
||||
nextSchedule.cron = normalizedCron;
|
||||
return nextSchedule;
|
||||
}
|
||||
|
||||
export function getScheduleSummary(schedule) {
|
||||
const normalizedSchedule = normalizeSchedule(schedule);
|
||||
|
||||
if (normalizedSchedule.type === SCHEDULE_TYPE.DAILY) {
|
||||
return `系统将在 每天 ${normalizedSchedule.time || DEFAULT_TIME} 自动触发入库。`;
|
||||
}
|
||||
|
||||
if (normalizedSchedule.type === SCHEDULE_TYPE.WEEKLY) {
|
||||
const weekdayLabel =
|
||||
WEEKDAY_OPTIONS.find((option) => option.value === normalizedSchedule.dayOfWeek)?.label || '周一';
|
||||
return `系统将在 每${weekdayLabel} ${normalizedSchedule.time || DEFAULT_TIME} 自动触发入库。`;
|
||||
}
|
||||
|
||||
return `按照 Cron 规则执行: ${normalizedSchedule.cron || DEFAULT_CRON}`;
|
||||
}
|
||||
|
||||
export function formatBackupTimestamp(value) {
|
||||
if (!value) return '未知';
|
||||
const timestamp = new Date(value);
|
||||
if (Number.isNaN(timestamp.getTime())) return value;
|
||||
return timestamp.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
Reference in New Issue
Block a user