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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-08 15:49:37 +08:00
parent 7d003ff822
commit be3c086975
17 changed files with 6389 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
{"env":{"browser":true,"es2021":true,"node":true},"extends":["eslint:recommended","plugin:react/recommended","plugin:react-hooks/recommended","plugin:jsx-a11y/recommended"],"parserOptions":{"ecmaFeatures":{"jsx":true},"ecmaVersion":"latest","sourceType":"module"},"root":true,"rules":{"no-console":["warn",{"allow":["warn","error"]}],"no-unused-vars":["warn",{"argsIgnorePattern":"^_"}],"react/prop-types":"warn","react/react-in-jsx-scope":"off"},"settings":{"react":{"version":"detect"}}}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
build/
coverage/
.env
.env.local
.DS_Store
*.log
+1
View File
@@ -0,0 +1 @@
{"arrowParens":"always","bracketSpacing":true,"endOfLine":"lf","printWidth":100,"semi":true,"singleQuote":true,"tabWidth":2,"trailingComma":"es5"}
@@ -0,0 +1,909 @@
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import {
CheckCircle2,
Headphones,
LoaderCircle,
Music2,
Pause,
Play,
Search,
ShieldAlert,
Sparkles,
Trash2
} from 'lucide-react';
import {
buildExceptionAudioUrl,
executeExceptionAction,
fetchExceptionItem,
fetchExceptionItems,
previewExceptionAction
} from '../api/exceptions';
import {
createRepairTaskStream,
fetchCurrentRepairTask,
fetchRepairTask,
fetchRepairTaskLogs
} from '../api/repairs';
// ── Constants (mirrored from ExceptionPage.jsx) ──────────────────────────────
const PROVIDER_MODES = [
{ id: 'all', label: '多源并行', providers: [] },
{ id: 'authoritative', label: '权威优先', providers: ['acoustid', 'musicbrainz'] },
{ id: 'netease', label: '网易云', providers: ['netease'] },
{ id: 'qq', label: 'QQ 音乐', providers: ['qq'] },
{ id: 'spotify', label: 'Spotify', providers: ['spotify'] }
];
const METADATA_FIELDS = ['title', 'artist', 'album', 'album_artist', 'track_number', 'disc_number', 'year', 'lyrics'];
const REQUIRED_FIELDS = ['title', 'artist', 'album_artist'];
const METADATA_QUEUE_TYPES = ['missing_tags'];
const METADATA_QUEUE_PAGE_SIZE = 100;
// ── Utility functions (mirrored from ExceptionPage.jsx) ────────────────────
function chipClass(active) {
return `rounded-full border px-3 py-1.5 text-xs transition ${
active
? 'border-cyan-400/50 bg-cyan-500/15 text-cyan-100'
: 'border-slate-700 bg-slate-900 text-slate-400 hover:border-slate-500'
}`;
}
function inputClass() {
return 'w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 outline-none transition placeholder:text-slate-500 focus:border-cyan-400/60';
}
function actionButtonClass(enabled) {
return `inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-xs font-medium transition ${
enabled
? 'border border-cyan-400/40 bg-cyan-500/15 text-cyan-100 hover:bg-cyan-500/20'
: 'cursor-not-allowed border border-slate-800 bg-slate-900 text-slate-600'
}`;
}
function riskClass(riskLevel) {
return {
low: 'bg-emerald-500/10 text-emerald-200',
medium: 'bg-amber-500/10 text-amber-200',
high: 'bg-rose-500/10 text-rose-200'
}[riskLevel || 'low'];
}
function formatTimestamp(value) {
if (!value) return '--';
try {
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
}).format(new Date(value));
} catch { return value; }
}
function formatSeconds(value) {
if (!Number.isFinite(value) || value <= 0) return '--:--';
const total = Math.floor(value);
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
function formatConfidence(value) {
if (value == null) return '--';
return `${Number(value).toFixed(1)}`;
}
function formatMetadataValue(value) {
if (value === null || value === undefined || value === '') return '--';
return String(value);
}
function providerLabel(provider) {
const labels = { acoustid: 'AcoustID', musicbrainz: 'MusicBrainz', netease: '网易云', qq: 'QQ 音乐', spotify: 'Spotify' };
const key = String(provider || '').toLowerCase();
return labels[key] || provider || '推荐候选';
}
function compareTimestampDesc(a, b) {
return new Date(b || 0).getTime() - new Date(a || 0).getTime();
}
function normalizeActionParams(action, params) {
if (action === 'retry_match') {
return { provider_mode: params.provider_mode || 'all', providers: params.providers || [] };
}
if (action === 'save_and_organize' || action === 'edit_metadata') {
return { metadata_patch: { ...(params.metadata_patch || {}) } };
}
return params;
}
function getMissingRequiredFields(metadata) {
return REQUIRED_FIELDS
.filter((field) => !String(metadata?.[field] || '').trim())
.map((field) => {
const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' };
return labels[field] || field;
});
}
function isTerminalRepairStatus(status) {
return status === 'completed' || status === 'failed';
}
// ── Sub-components ──────────────────────────────────────────────────────────
function InfoField({ label, value, mono = false }) {
return (
<div className="rounded-2xl border border-slate-800 bg-slate-900/70 p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-slate-500">{label}</div>
<div className={`mt-1 text-sm text-slate-100 ${mono ? 'break-all font-mono text-[11px]' : ''}`}>{value || '--'}</div>
</div>
);
}
function ErrorText({ message }) {
return <p className="mt-3 text-xs text-rose-300">{message}</p>;
}
function renderInlineBadge(status) {
if (!status) return null;
const map = {
submitting: 'border-amber-500/30 bg-amber-500/10 text-amber-200',
accepted: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200',
running: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-200',
completed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200',
failed: 'border-rose-500/30 bg-rose-500/10 text-rose-200'
};
const label = { submitting: '提交中', accepted: '已提交', running: '执行中', completed: '已完成', failed: '失败' }[status];
if (!label) return null;
return <span className={`rounded-full border px-2.5 py-1 text-xs ${map[status]}`}>{label}</span>;
}
// Memoized queue item component to prevent unnecessary re-renders
const QueueItem = memo(({ item, selectedId, onSelect }) => {
const ap = item.audio_props_json || {};
const selected = selectedId === item.exception_id;
return (
<button
onClick={() => onSelect(item.exception_id)}
className={`w-full rounded-2xl border p-4 text-left transition ${
selected ? 'border-cyan-400/60 bg-cyan-500/10 shadow-[0_0_0_1px_rgba(34,211,238,0.08)]'
: 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:bg-slate-900'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-slate-100">{item.display_title}</div>
<div className="mt-1 truncate font-mono text-[11px] text-cyan-300/80">{item.filename}</div>
</div>
<span className="shrink-0 rounded-full border border-slate-700 bg-slate-950 px-2.5 py-1 text-[11px] text-slate-300">
{item.type_label}
</span>
</div>
<div className="mt-3 line-clamp-2 text-xs leading-5 text-slate-400">{item.display_reason}</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-slate-500">
<span>{formatSeconds(ap.duration_seconds)}</span>
<span className="text-right">{ap.bitrate ? `${Math.round(ap.bitrate / 1000)} kbps` : '--'}</span>
</div>
</button>
);
});
// ── Main component ──────────────────────────────────────────────────────────
export default function MissingTagsInlinePanel({
onSwitchToAdvanced
}) {
// Queue state
const [queue, setQueue] = useState([]);
const [isQueueLoading, setIsQueueLoading] = useState(true);
const [queueError, setQueueError] = useState('');
// Selected item state
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
// Metadata editor state
const [metadataPatch, setMetadataPatch] = useState({});
const [providerMode, setProviderMode] = useState('all');
const [providers, setProviders] = useState([]);
// Combined state using useReducer
const [previewState, setPreviewState] = useReducer(
(state, action) => {
switch (action.type) {
case 'START': return { ...state, loading: true, error: '', action: action.action };
case 'SUCCESS': return { ...state, loading: false, payload: action.payload, error: '' };
case 'ERROR': return { ...state, loading: false, payload: null, error: action.error };
case 'CLEAR': return { loading: false, payload: null, error: '', action: '' };
default: return state;
}
},
{ loading: false, payload: null, error: '', action: '' }
);
const [executionState, setExecutionState] = useReducer(
(state, action) => {
switch (action.type) {
case 'SUBMIT': return { ...action.payload, status: 'submitting', repairTaskId: null, error: '' };
case 'ACCEPT': return { ...state, status: 'accepted', repairTaskId: action.repairTaskId, error: '' };
case 'RUNNING': return { ...state, status: 'running' };
case 'COMPLETE': return { ...state, status: 'completed' };
case 'FAIL': return { ...state, status: 'failed', error: action.error };
case 'CLEAR': return null;
default: return state;
}
},
null
);
const [repairTask, setRepairTask] = useState(null);
const [repairLogs, setRepairLogs] = useState([]);
const completedRefreshRef = useRef(new Set());
// Derived values
const draft = metadataPatch && Object.keys(metadataPatch).length > 0
? metadataPatch
: detail?.effective_metadata || {};
const missingFields = getMissingRequiredFields(draft);
const canIngest = missingFields.length === 0 && (detail?.available_actions || []).includes('save_and_organize');
const candidates = detail?.match_candidates_json || [];
const finalPreviewItem = previewState.action === 'save_and_organize' && previewState.payload
? previewState.payload.items?.find((item) => item.exception_id === selectedId) || null
: null;
const finalPreview = finalPreviewItem?.final_library_preview || null;
// ── Load queue ──────────────────────────────────────────────────────────
const loadQueue = useCallback((keepSelection = true) => {
setIsQueueLoading(true);
setQueueError('');
Promise.all(
METADATA_QUEUE_TYPES.map((type) =>
fetchExceptionItems({ type, resolutionStatus: 'open', page: 1, pageSize: METADATA_QUEUE_PAGE_SIZE })
)
)
.then((payloads) => {
const all = payloads
.flatMap((p) => p.items)
.sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at));
setQueue(all);
if (keepSelection) {
setSelectedId((prev) => {
if (!all.length) return null;
if (prev && all.some((item) => item.exception_id === prev)) return prev;
return all[0]?.exception_id || null;
});
}
})
.catch((err) => setQueueError(err.message || '队列加载失败'))
.finally(() => setIsQueueLoading(false));
}, []);
// ── Load detail ──────────────────────────────────────────────────────────
useEffect(() => {
if (!selectedId) { setDetail(null); return; }
const controller = new AbortController();
setIsDetailLoading(true);
setDetailError('');
fetchExceptionItem(selectedId, { signal: controller.signal })
.then((payload) => {
setDetail(payload);
const md = payload.effective_metadata || payload.matched_metadata_json || payload.original_tags_json || {};
setMetadataPatch({
title: md.title || '', artist: md.artist || '', album: md.album || '',
album_artist: md.album_artist || '', track_number: md.track_number ?? null,
disc_number: md.disc_number ?? null, year: md.year ?? null, lyrics: md.lyrics || ''
});
setPreviewState({ type: 'CLEAR' });
setExecutionState({ type: 'CLEAR' });
})
.catch((err) => {
if (err.name !== 'AbortError') { setDetail(null); setDetailError(err.message || '详情加载失败'); }
})
.finally(() => { if (!controller.signal.aborted) setIsDetailLoading(false); });
return () => controller.abort();
}, [selectedId]);
// ── Initial load ─────────────────────────────────────────────────────────
useEffect(() => { loadQueue(false); }, []);
useEffect(() => {
fetchCurrentRepairTask().then((p) => {
if (p.task) { setRepairTask(p.task); fetchRepairTaskLogs(p.task.task_id, 1, 20).then((lp) => setRepairLogs(lp.logs)); }
}).catch(() => {});
}, []);
// ── Repair task WebSocket ────────────────────────────────────────────────
useEffect(() => {
if (!repairTask?.task_id) return;
let socket = null;
let isMounted = true;
const setupSocket = () => {
socket = createRepairTaskStream(repairTask.task_id);
socket.onmessage = async (event) => {
if (!isMounted) return;
const p = JSON.parse(event.data);
if (p.type === 'task.snapshot') {
setRepairTask(p.data.task);
setRepairLogs(p.data.recent_logs || []);
return;
}
try {
const tp = await fetchRepairTask(repairTask.task_id);
const lp = await fetchRepairTaskLogs(repairTask.task_id, 1, 20);
if (isMounted) {
setRepairTask(tp.task);
setRepairLogs(lp.logs);
}
} catch (err) {
console.error('Failed to refresh repair task state', err);
}
};
socket.onerror = (err) => {
console.error('Repair task WebSocket error', err);
};
};
setupSocket();
return () => {
isMounted = false;
if (socket) {
try {
socket.close();
} catch (err) {
console.warn('WebSocket close error', err);
}
socket = null;
}
};
}, [repairTask?.task_id]);
// ── React to repair task completion ─────────────────────────────────────
useEffect(() => {
if (!repairTask?.task_id) return;
setExecutionState((prev) => {
if (!prev || prev.repairTaskId !== repairTask.task_id) return prev;
const nextStatus = repairTask.status === 'completed' ? 'completed'
: repairTask.status === 'failed' ? 'failed'
: repairTask.status === 'running' ? 'running' : 'accepted';
if (prev.status === nextStatus) return prev;
return { ...prev, status: nextStatus, error: repairTask.status === 'failed' ? repairTask.error_message || '执行失败' : '' };
});
if (isTerminalRepairStatus(repairTask.status) && !completedRefreshRef.current.has(repairTask.task_id)) {
completedRefreshRef.current.add(repairTask.task_id);
loadQueue(true);
}
}, [repairTask]);
// ── Metadata update handler ──────────────────────────────────────────────
const updateMetadata = useCallback((key, value) => {
setMetadataPatch((prev) => ({ ...prev, [key]: value }));
setPreviewState({ type: 'CLEAR' });
}, []);
// ── Preview handler ──────────────────────────────────────────────────────
const handlePreview = useCallback(async (action) => {
if (!selectedId || !action) return;
const params = normalizeActionParams(action, {
metadata_patch: metadataPatch,
provider_mode: providerMode,
providers
});
setPreviewState({ type: 'START', action });
try {
const payload = await previewExceptionAction({ exception_ids: [selectedId], action, params });
setPreviewState({ type: 'SUCCESS', payload });
} catch (err) {
setPreviewState({ type: 'ERROR', error: err.message || '预览生成失败' });
}
}, [selectedId, metadataPatch, providerMode, providers]);
// ── Execute handler ──────────────────────────────────────────────────────
const handleExecute = useCallback(async (action) => {
if (!selectedId || !action) return;
if (action === 'delete_file') {
if (!window.confirm('将永久删除选中的文件,且无法恢复。是否继续?')) return;
if (!window.confirm('请再次确认:这会真实删除文件,不是忽略。是否执行删除?')) return;
}
setExecutionState({ type: 'SUBMIT', payload: { exceptionId: selectedId, action, submittedAt: new Date().toISOString() } });
try {
const params = normalizeActionParams(action, {
metadata_patch: metadataPatch,
provider_mode: providerMode,
providers
});
const payload = await executeExceptionAction({ exception_ids: [selectedId], action, params });
setExecutionState({ type: 'ACCEPT', repairTaskId: payload.repair_task_id });
const tp = await fetchRepairTask(payload.repair_task_id);
const lp = await fetchRepairTaskLogs(payload.repair_task_id, 1, 20);
setRepairTask(tp.task);
setRepairLogs(lp.logs);
} catch (err) {
setExecutionState({ type: 'FAIL', error: err.message || '执行失败' });
}
}, [selectedId, metadataPatch, providerMode, providers]);
// ── Smart ingest: preview then execute ────────────────────────────────────
const handleIngest = useCallback(async () => {
if (!canIngest) return;
try {
const params = normalizeActionParams('save_and_organize', { metadata_patch: metadataPatch, provider_mode: providerMode, providers });
const previewP = await previewExceptionAction({ exception_ids: [selectedId], action: 'save_and_organize', params });
setPreviewState({ type: 'SUCCESS', payload: previewP, action: 'save_and_organize' });
// Directly execute after preview
await handleExecute('save_and_organize');
} catch (err) {
setPreviewState({ type: 'ERROR', error: err.message || '预览生成失败' });
// Also clear any stale execution state
setExecutionState({ type: 'CLEAR' });
}
}, [canIngest, metadataPatch, providerMode, providers, selectedId, handleExecute]);
// ── Audio player state ───────────────────────────────────────────────────
const audioRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [audioError, setAudioError] = useState('');
const audioUrl = detail ? buildExceptionAudioUrl(detail.exception_id) : '';
const audioProps = detail?.audio_props_json || {};
useEffect(() => {
setIsPlaying(false); setCurrentTime(0); setDuration(0); setAudioError('');
if (audioRef.current) { audioRef.current.pause(); audioRef.current.load(); }
}, [audioUrl]);
const togglePlay = useCallback(() => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play().catch(() => setAudioError('播放器启动失败'));
} else {
audioRef.current.pause();
}
}, []);
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="flex min-h-[calc(100vh-120px)] flex-col gap-6 py-6">
{/* Header */}
<section className="rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.08),_transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
<div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-cyan-300/70">元数据缺失 · 快速补全</p>
<h2 className="-mt-1 flex items-center gap-3 text-2xl font-semibold text-white">
<Music2 className="h-6 w-6 text-cyan-300" />
元数据缺失处理
</h2>
<p className="mt-2 max-w-3xl text-sm text-slate-400">
左侧编辑元数据右侧实时预览入库路径补全必填字段后一键入库
</p>
</div>
<div className="flex items-start gap-3">
<button onClick={onSwitchToAdvanced} className={actionButtonClass(true)}>
高级处理
</button>
</div>
</div>
</section>
{/* Main content: queue + workspace */}
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[minmax(300px,0.32fr)_minmax(0,1fr)]">
{/* ── Left: Queue ────────────────────────────────────────────────── */}
<section className="min-h-[420px] overflow-hidden rounded-[28px] border border-slate-800/90 bg-slate-950/80 shadow-[0_24px_80px_rgba(2,6,23,0.35)]">
<div className="border-b border-slate-800/80 p-5">
<p className="text-xs uppercase tracking-[0.22em] text-slate-500">元数据缺失队列</p>
<h3 className="mt-2 text-lg font-semibold text-white">{queue.length} 个待处理</h3>
</div>
<div className="max-h-[calc(100vh-330px)] min-h-[320px] overflow-auto p-3">
{isQueueLoading ? (
<div className="flex min-h-[280px] items-center justify-center text-sm text-slate-500">正在加载...</div>
) : queueError ? (
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">{queueError}</div>
) : !queue.length ? (
<div className="flex min-h-[280px] flex-col items-center justify-center text-center text-slate-500">
<CheckCircle2 className="mb-4 h-12 w-12 text-emerald-300/60" />
<p className="text-sm text-slate-300">元数据缺失异常已处理完成</p>
</div>
) : (
<div className="space-y-2">
{queue.map((item) => (
<QueueItem
key={item.exception_id}
item={item}
selectedId={selectedId}
onSelect={setSelectedId}
/>
))}
</div>
)}
</div>
</section>
{/* ── Right: Workspace ───────────────────────────────────────────── */}
<section className="min-h-0 overflow-auto rounded-[28px] border border-slate-800/90 bg-[radial-gradient(circle_at_top_right,_rgba(34,197,94,0.08),_transparent_28%),linear-gradient(180deg,rgba(15,23,42,0.98),rgba(2,6,23,0.98))] p-5 shadow-[0_24px_80px_rgba(2,6,23,0.45)]">
{!detail && !isDetailLoading ? (
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-slate-500">
<Music2 className="mb-4 h-12 w-12 opacity-30" />
<p className="text-sm">从左侧队列选择一个文件开始处理</p>
</div>
) : detailError ? (
<div className="rounded-2xl border border-rose-900/50 bg-rose-950/20 p-4 text-sm text-rose-200">{detailError}</div>
) : isDetailLoading ? (
<div className="flex h-full min-h-[420px] items-center justify-center">
<LoaderCircle className="h-8 w-8 animate-spin text-cyan-300/60" />
</div>
) : (
<div className="space-y-4 pb-8">
{/* File summary */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">当前文件</p>
<h3 className="mt-2 truncate text-lg font-semibold text-white">{detail.display_title || '-'}</h3>
<p className="mt-1 truncate font-mono text-[11px] text-cyan-300/80">{detail.filename || '-'}</p>
</div>
<span className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2.5 py-1 text-xs text-rose-200">开放中</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-300">
<InfoField label="匹配来源" value={detail.match_source || '--'} />
<InfoField label="匹配分数" value={formatConfidence(detail.match_confidence)} />
<InfoField label="编码" value={audioProps.codec || '--'} />
<InfoField label="时长" value={formatSeconds(audioProps.duration_seconds)} />
</div>
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
{detail.display_reason || '-'}
</div>
</div>
{/* Two-column layout: editor | preview */}
<div className="grid gap-4 xl:grid-cols-[1fr_minmax(320px,0.9fr)]">
{/* ── Left column: Edit ─────────────────────────────────── */}
<div className="space-y-4">
{/* Audio player */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">试听预览</p>
<h4 className="mt-2 flex items-center gap-2 text-sm font-medium text-white">
<Headphones className="h-4 w-4 text-emerald-300" />在线试听
</h4>
</div>
<button
onClick={togglePlay}
className="flex h-11 w-11 items-center justify-center rounded-full border border-emerald-500/40 bg-emerald-500/10 text-emerald-200 transition hover:bg-emerald-500/20"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
</button>
</div>
<audio ref={audioRef} src={audioUrl} preload="metadata"
onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
onError={() => setAudioError('音频文件不可用或已丢失')}
className="hidden"
/>
<div className="mt-4">
<input type="range" min="0" max={duration || 0} step="0.1"
value={Math.min(currentTime, duration || 0)}
onChange={(e) => { const v = Number(e.target.value); setCurrentTime(v); if (audioRef.current) audioRef.current.currentTime = v; }}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-800 accent-emerald-400"
/>
<div className="mt-2 flex items-center justify-between font-mono text-[11px] text-slate-500">
<span>{formatSeconds(currentTime)}</span><span>{formatSeconds(duration)}</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-300">
<InfoField label="格式" value={audioProps.format || '--'} />
<InfoField label="采样率" value={audioProps.sample_rate ? `${audioProps.sample_rate} Hz` : '--'} />
<InfoField label="比特率" value={audioProps.bitrate ? `${Math.round(audioProps.bitrate / 1000)} kbps` : '--'} />
<InfoField label="位深" value={audioProps.bit_depth ? `${audioProps.bit_depth} bit` : '--'} />
</div>
{audioError ? <p className="mt-3 text-xs text-rose-300">{audioError}</p> : null}
</div>
{/* Match retry */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<h4 className="text-sm font-medium text-white">重新匹配</h4>
<p className="mt-2 text-xs leading-5 text-slate-400">选择匹配来源后执行重新匹配结果会自动填充到下方编辑区</p>
<div className="mt-4 flex flex-wrap gap-2">
{PROVIDER_MODES.map((mode) => {
const active = providerMode === mode.id;
return (
<button key={mode.id} onClick={() => { setProviderMode(mode.id); setProviders(mode.providers); }}
className={chipClass(active)}>
{mode.label}
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button onClick={() => { handlePreview('retry_match'); }} className={actionButtonClass(true)}>
<Search className="h-3.5 w-3.5" />预览匹配
</button>
<button onClick={() => handleExecute('retry_match')}
className="rounded-xl bg-cyan-500 px-3 py-2 text-sm font-medium text-slate-950">
执行匹配
</button>
</div>
{previewState.action === 'retry_match' && previewState.loading ? (
<div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成匹配预览...
</div>
) : null}
{executionState?.action === 'retry_match' ? (
<div className="mt-3">{renderInlineBadge(executionState.status)}</div>
) : null}
</div>
{/* Metadata editor */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium text-white">元数据编辑</h4>
<p className="mt-2 text-xs leading-5 text-slate-400">补全 title / artist / album_artist 后可入库</p>
</div>
<span className={`rounded-full border px-2.5 py-1 text-[11px] ${
canIngest ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
: 'border-rose-500/30 bg-rose-500/10 text-rose-200'
}`}>
{canIngest ? '可入库' : '缺少必填'}
</span>
</div>
{detail.album_artist_reason ? (
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-300">
{detail.album_artist_reason}
</div>
) : null}
<div className="mt-4 grid grid-cols-2 gap-2">
<input className={inputClass()} value={draft.title || ''} onChange={(e) => updateMetadata('title', e.target.value)} placeholder="标题 *" />
<input className={inputClass()} value={draft.artist || ''} onChange={(e) => updateMetadata('artist', e.target.value)} placeholder="艺术家 *" />
<input className={inputClass()} value={draft.album_artist || ''} onChange={(e) => updateMetadata('album_artist', e.target.value)} placeholder="专辑艺术家 *" />
<input className={inputClass()} value={draft.album || ''} onChange={(e) => updateMetadata('album', e.target.value)} placeholder="专辑" />
<input className={inputClass()} value={draft.track_number ?? ''} onChange={(e) => updateMetadata('track_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="曲目号" />
<input className={inputClass()} value={draft.disc_number ?? ''} onChange={(e) => updateMetadata('disc_number', e.target.value === '' ? null : Number(e.target.value))} placeholder="碟号" />
<input className={inputClass()} value={draft.year ?? ''} onChange={(e) => updateMetadata('year', e.target.value === '' ? null : Number(e.target.value))} placeholder="年份" />
</div>
<textarea className={`${inputClass()} mt-2 min-h-[96px] resize-y`}
value={draft.lyrics || ''} onChange={(e) => updateMetadata('lyrics', e.target.value)} placeholder="歌词" />
<div className="mt-4 grid grid-cols-3 gap-2 text-xs">
{REQUIRED_FIELDS.map((field) => {
const labels = { title: '标题', artist: '艺术家', album_artist: '专辑艺术家' };
const present = String(draft[field] || '').trim();
return (
<div key={field} className={`rounded-2xl border px-3 py-2 ${
present ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-100'
: 'border-rose-500/30 bg-rose-500/10 text-rose-100'
}`}>{labels[field]}</div>
);
})}
</div>
{/* Candidate preview (if preview matched) */}
{previewState.action === 'retry_match' && previewState.payload && !previewState.loading ? (
<div className="mt-4 rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
<ShieldAlert className="h-4 w-4 text-amber-300" />匹配预览结果
</h5>
<div className="mt-3 space-y-2">
{previewState.payload.items?.map((item) => (
<div key={item.exception_id} className="rounded-2xl border border-amber-900/30 bg-slate-950/60 p-3 text-xs text-slate-300">
<div className="font-medium text-slate-100">{item.filename}</div>
<div className="mt-2 space-y-1">
{item.planned_operations?.map((op, i) => (
<div key={`${op.type}-${i}`} className="text-slate-400">{op.description}</div>
))}
</div>
</div>
))}
</div>
</div>
) : null}
{previewState.error && previewState.action === 'retry_match' ? <ErrorText message={previewState.error} /> : null}
</div>
</div>
{/* ── Right column: Preview ──────────────────────────────── */}
<div className="space-y-4">
{/* Refresh preview button */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<h4 className="text-sm font-medium text-white">入库预览</h4>
<p className="mt-2 text-xs leading-5 text-slate-400">
点击下方按钮生成后端计算的最终元数据和入库路径
</p>
<button onClick={() => handlePreview('save_and_organize')}
disabled={!canIngest}
className={`mt-4 inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium transition ${
canIngest ? 'bg-slate-100 text-slate-900' : 'cursor-not-allowed bg-slate-800 text-slate-500'
}`}>
<Sparkles className="h-3.5 w-3.5" />刷新入库预览
</button>
{previewState.action === 'save_and_organize' && previewState.loading ? (
<div className="mt-4 flex items-center gap-2 rounded-2xl border border-slate-800 bg-slate-900/70 p-3 text-xs text-slate-400">
<LoaderCircle className="h-4 w-4 animate-spin" />正在生成入库确认...
</div>
) : previewState.error && previewState.action === 'save_and_organize' ? (
<ErrorText message={previewState.error} />
) : finalPreview ? (
<div className="mt-4 space-y-3">
{/* Target paths */}
<div className="rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
<div className="flex items-center justify-between gap-3">
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
<ShieldAlert className="h-4 w-4 text-amber-300" />入库确认
</h5>
<span className={`rounded-full px-2 py-1 text-[11px] ${riskClass(previewState.payload?.risk_level)}`}>
风险 {previewState.payload?.risk_level}
</span>
</div>
<div className="mt-3 grid gap-3 text-xs text-slate-300">
<InfoField label="目标相对路径" value={finalPreview.target_relative_path} mono />
<InfoField label="完整目标文件路径" value={finalPreview.target_file_path} mono />
</div>
</div>
{/* Final metadata table */}
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/60">
<table className="min-w-[620px] w-full border-collapse text-left text-xs">
<thead className="bg-slate-900/70 text-[11px] uppercase tracking-[0.14em] text-slate-500">
<tr>
<th className="w-40 px-3 py-3 font-medium">字段</th>
<th className="px-3 py-3 font-medium">最终值</th>
<th className="w-36 px-3 py-3 font-medium">来源</th>
</tr>
</thead>
<tbody>
{METADATA_FIELDS.map((field) => (
<tr key={field} className="border-t border-slate-800/80">
<td className="px-3 py-3 font-mono text-[11px] text-cyan-100">{field}</td>
<td className="px-3 py-3 whitespace-pre-wrap break-all text-slate-100">
{formatMetadataValue(finalPreview.metadata?.[field])}
</td>
<td className="px-3 py-3 text-slate-400">{finalPreview.metadata_sources?.[field] || '--'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Planned operations */}
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
<h5 className="text-sm font-medium text-white">计划操作</h5>
<div className="mt-3 space-y-2 text-xs text-slate-300">
{(finalPreviewItem?.planned_operations || []).map((op, i) => (
<div key={`${op.type}-${i}`} className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
<div className="text-slate-100">{op.description}</div>
{op.target_path ? (
<div className="mt-1 break-all font-mono text-[11px] text-slate-500">{op.target_path}</div>
) : null}
</div>
))}
</div>
{(finalPreviewItem?.warnings || []).concat(previewState.payload?.warnings || []).length > 0 ? (
<div className="mt-3 space-y-1 text-xs text-amber-200">
{finalPreviewItem?.warnings?.map((w, i) => <div key={`iw-${i}`}>{w}</div>)}
{previewState.payload?.warnings?.map((w, i) => <div key={`pw-${i}`}>{w}</div>)}
</div>
) : null}
</div>
</div>
) : null}
</div>
</div>
</div>
{/* ── Action bar ────────────────────────────────────────────── */}
<div className="rounded-[24px] border border-slate-800/80 bg-slate-950/75 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium text-white">执行操作</h4>
<p className="mt-1 text-xs text-slate-400">入库前请确认右侧预览结果忽略只改状态删除会真实删除文件</p>
</div>
{renderInlineBadge(executionState?.status)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{/* Primary: Ingest */}
<button onClick={handleIngest} disabled={!canIngest}
className={`rounded-xl px-3 py-2 text-sm font-medium transition ${
canIngest ? 'bg-cyan-500 text-slate-950 hover:bg-cyan-400' : 'cursor-not-allowed bg-slate-800 text-slate-500'
}`}>
入库
</button>
{/* Save draft */}
<button onClick={() => handleExecute('edit_metadata')}
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
保存草稿
</button>
{/* Ignore */}
<button onClick={() => { handlePreview('ignore_exception'); }}
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
预览忽略
</button>
<button onClick={() => handleExecute('ignore_exception')}
className="rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm font-medium text-slate-100">
确认忽略
</button>
{/* Delete */}
<button onClick={() => { handlePreview('delete_file'); }}
className="rounded-xl bg-rose-100 px-3 py-2 text-sm font-medium text-rose-950">
预览删除
</button>
<button onClick={() => handleExecute('delete_file')}
className="rounded-xl bg-rose-500 px-3 py-2 text-sm font-medium text-white">
删除文件
</button>
</div>
{/* Preview for ignore/delete */}
{(previewState.action === 'ignore_exception' || previewState.action === 'delete_file') && previewState.payload && !previewState.loading ? (
<div className="mt-4 rounded-2xl border border-amber-900/40 bg-amber-950/20 p-4">
<div className="flex items-center justify-between gap-3">
<h5 className="flex items-center gap-2 text-sm font-medium text-white">
<ShieldAlert className="h-4 w-4 text-amber-300" />预览结果
</h5>
<span className={`rounded-full px-2 py-1 text-[11px] ${riskClass(previewState.payload?.risk_level)}`}>
风险 {previewState.payload?.risk_level}
</span>
</div>
<div className="mt-3 space-y-2">
{previewState.payload.items?.map((item) => (
<div key={item.exception_id} className="rounded-2xl border border-amber-900/30 bg-slate-950/60 p-3 text-xs text-slate-300">
<div className="font-medium text-slate-100">{item.filename}</div>
<div className="mt-2 space-y-1">
{item.planned_operations?.map((op, i) => (
<div key={`${op.type}-${i}`} className="text-slate-400">{op.description}</div>
))}
</div>
</div>
))}
</div>
</div>
) : null}
{executionState?.error ? <ErrorText message={executionState.error} /> : null}
{/* Execution feedback */}
{executionState?.status ? (
<div className="mt-4 rounded-2xl border border-cyan-900/40 bg-cyan-950/20 p-3 text-xs text-cyan-100/85">
<div className="font-medium">
{executionState.status === 'submitting' ? '正在提交执行请求'
: executionState.status === 'accepted' ? '任务已提交'
: executionState.status === 'running' ? '任务执行中'
: executionState.status === 'completed' ? '执行完成'
: executionState.status === 'failed' ? '执行失败' : ''}
</div>
<div className="mt-1 text-slate-400">
{executionState.repairTaskId ? `任务号 ${executionState.repairTaskId}` : '等待返回任务号'}
{executionState.submittedAt ? `,提交时间 ${formatTimestamp(executionState.submittedAt)}` : ''}
</div>
{executionState.error ? <div className="mt-1 text-rose-200">{executionState.error}</div> : null}
</div>
) : null}
{/* Repair task logs */}
{repairTask && repairLogs.length > 0 && executionState?.repairTaskId === repairTask.task_id ? (
<div className="mt-4 rounded-2xl border border-slate-800 bg-slate-950/60 p-3">
<div className="flex items-center gap-2 text-xs text-slate-400">
<LoaderCircle className={`h-3 w-3 ${!isTerminalRepairStatus(repairTask.status) ? 'animate-spin' : ''}`} />
任务日志 ({repairTask.status})
</div>
<div className="mt-2 max-h-[160px] overflow-auto space-y-1 font-mono text-[11px] text-slate-500">
{repairLogs.map((log, i) => (
<div key={i}>{log.message || log.stage || '--'}</div>
))}
</div>
</div>
) : null}
</div>
</div>
)}
</section>
</div>
</div>
);
}