diff --git a/frontend/src/components/workbench/StageStatsPanel.jsx b/frontend/src/components/workbench/StageStatsPanel.jsx
new file mode 100644
index 0000000..639c016
--- /dev/null
+++ b/frontend/src/components/workbench/StageStatsPanel.jsx
@@ -0,0 +1,62 @@
+// frontend/src/components/workbench/StageStatsPanel.jsx
+import { STAGE_STATS_CONFIG, STAGE_EXTRA_FIELDS } from '../../utils/workbench';
+
+export default function StageStatsPanel({ stageName, stats }) {
+ const config = STAGE_STATS_CONFIG[stageName];
+ const extraField = STAGE_EXTRA_FIELDS[stageName];
+ if (!config || !stats) return null;
+
+ const stageLabels = {
+ scan: 'Scan', preprocess: 'Preprocess',
+ match: 'Match', dedupe: 'Dedupe', organize: 'Organize'
+ };
+
+ return (
+
+
+ {stageLabels[stageName] || stageName}
+
+
+ {config.map(({ key, label, color, error }) => (
+
+ ))}
+
+ {extraField && (
+
+
{extraField.label}
+
+ {stats[extraField.key] ?? 0}
+
+
+ )}
+
+ );
+}
+
+function StatCard({ label, value, color, error }) {
+ const colorMap = {
+ white: 'text-white', emerald: 'text-emerald-400',
+ amber: 'text-amber-400', rose: 'text-rose-400',
+ blue: 'text-blue-300', cyan: 'text-cyan-300',
+ indigo: 'text-indigo-300', sky: 'text-sky-300',
+ slate: 'text-slate-300', orange: 'text-orange-300'
+ };
+ const labelMap = {
+ emerald: 'text-emerald-500', amber: 'text-amber-500',
+ rose: 'text-rose-500', blue: 'text-blue-400',
+ cyan: 'text-cyan-400', indigo: 'text-indigo-400', sky: 'text-sky-400'
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/workbench/TaskControlPanel.jsx b/frontend/src/components/workbench/TaskControlPanel.jsx
new file mode 100644
index 0000000..ab10303
--- /dev/null
+++ b/frontend/src/components/workbench/TaskControlPanel.jsx
@@ -0,0 +1,111 @@
+// frontend/src/components/workbench/TaskControlPanel.jsx
+import { useNavigate } from 'react-router-dom';
+import {
+ Activity, AlertTriangle, CheckCircle2, Edit3,
+ Folder, Play, Settings
+} from 'lucide-react';
+
+function DirectoryField({ label, value, missingText }) {
+ return (
+
+
+
+ {value || missingText}
+
+
+ );
+}
+
+export default function TaskControlPanel({
+ config, canStart, isRunning, isCompleted, isFailed,
+ onStart, isStarting, errorMessage, activeStageName, task
+}) {
+ const navigate = useNavigate();
+
+ return (
+ <>
+
+
+
+
+ 处理编排
+
+
+
+
+
+
+
+ {!canStart && (
+
+
+
+ )}
+
+
+
+
+ {isRunning ? (
+
+
+
后台任务执行中
+
{activeStageName}正在后台执行,实时日志、去重决策与入库路径会持续刷新。
+
+ ) : isFailed ? (
+
+
+
后台任务失败
+
{task?.error_message || '请查看任务记录流排查原因。'}
+
+
+ ) : (
+
+ {isCompleted && (
+
+
+
+ )}
+
+ {isCompleted ? '五阶段任务已完成' : '准备启动后台任务'}
+
+
+
+ )}
+
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+ >
+ );
+}
diff --git a/frontend/src/components/workbench/TaskFileList.jsx b/frontend/src/components/workbench/TaskFileList.jsx
new file mode 100644
index 0000000..8cf72e4
--- /dev/null
+++ b/frontend/src/components/workbench/TaskFileList.jsx
@@ -0,0 +1,71 @@
+// frontend/src/components/workbench/TaskFileList.jsx
+import { FileSearch } from 'lucide-react';
+
+function StatusBadge({ status, variant = 'scan' }) {
+ const scanMap = {
+ queued: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400',
+ skipped_locked: 'border-amber-500/20 bg-amber-500/10 text-amber-400',
+ invalid: 'border-rose-500/20 bg-rose-500/10 text-rose-400'
+ };
+ const scanLabel = { queued: '已入队', skipped_locked: '已跳过', invalid: '无效' };
+ const preprocessMap = {
+ pending: 'border-slate-700 bg-slate-800 text-slate-300',
+ skipped: 'border-slate-700 bg-slate-800 text-slate-400',
+ running: 'border-blue-500/20 bg-blue-500/10 text-blue-300',
+ completed: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400',
+ warning: 'border-amber-500/20 bg-amber-500/10 text-amber-400',
+ failed: 'border-rose-500/20 bg-rose-500/10 text-rose-400',
+ replaced_by_split: 'border-cyan-500/20 bg-cyan-500/10 text-cyan-300'
+ };
+ const preprocessLabel = {
+ pending: '待预处理', skipped: '不处理', running: '处理中',
+ completed: '已完成', warning: '有警告', failed: '失败', replaced_by_split: '已切轨'
+ };
+
+ if (variant === 'scan') {
+ const cls = scanMap[status] || 'border-slate-700 bg-slate-800 text-slate-300';
+ const label = scanLabel[status] || status;
+ return {label};
+ }
+ const cls = preprocessMap[status] || 'border-slate-700 bg-slate-800 text-slate-300';
+ const label = preprocessLabel[status] || status;
+ return {label};
+}
+
+export default function TaskFileList({ items, isLoading }) {
+ return (
+
+
+ 任务文件
+
+
+
+ {isLoading ? (
+
正在加载任务数据...
+ ) : items.length === 0 ? (
+
当前暂无任务文件
+ ) : (
+ items.map((item) => (
+
+
+
+
{item.relative_path}
+
+ {item.local_cover ? '已挂载封面' : '无本地封面'} / {item.local_lyric ? '已挂载歌词' : '无本地歌词'}
+
+ {item.preprocess_message && (
+
{item.preprocess_message}
+ )}
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/workbench/TaskInfoPanel.jsx b/frontend/src/components/workbench/TaskInfoPanel.jsx
new file mode 100644
index 0000000..5d3693b
--- /dev/null
+++ b/frontend/src/components/workbench/TaskInfoPanel.jsx
@@ -0,0 +1,26 @@
+// frontend/src/components/workbench/TaskInfoPanel.jsx
+
+function InfoRow({ label, value }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export default function TaskInfoPanel({ task, latestLogId, hasMoreLogs }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/workbench/TaskLogStream.jsx b/frontend/src/components/workbench/TaskLogStream.jsx
new file mode 100644
index 0000000..fe09546
--- /dev/null
+++ b/frontend/src/components/workbench/TaskLogStream.jsx
@@ -0,0 +1,34 @@
+// frontend/src/components/workbench/TaskLogStream.jsx
+import { ListChecks } from 'lucide-react';
+import { formatLogTime } from '../../utils/workbench';
+
+export default function TaskLogStream({ logs, logsEndRef }) {
+ return (
+
+
+
+ 任务记录流
+
+
+ {logs.length === 0 ? (
+
等待任务启动...
+ ) : (
+ logs.map((log) => (
+
+ [{formatLogTime(log.created_at)}]
+
+ {log.message}
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/workbench/TaskProgressBar.jsx b/frontend/src/components/workbench/TaskProgressBar.jsx
new file mode 100644
index 0000000..43c337d
--- /dev/null
+++ b/frontend/src/components/workbench/TaskProgressBar.jsx
@@ -0,0 +1,89 @@
+// frontend/src/components/workbench/TaskProgressBar.jsx
+import { Check } from 'lucide-react';
+import { STAGES, getStageIndex } from '../../constants';
+
+export default function TaskProgressBar({ task, isRunning, isCompleted, isFailed, latestFile }) {
+ const stageIndex = getStageIndex(task?.current_stage || 'scan');
+ const DEFAULT_STAGE_STATES = {
+ scan: 'pending', preprocess: 'pending', match: 'pending',
+ dedupe: 'pending', organize: 'pending', complete: 'pending'
+ };
+
+ const activeStageName = getStageName(task?.current_stage || 'scan');
+ const progressLabel = isRunning
+ ? `${activeStageName}进行中`
+ : isCompleted ? '五阶段处理已完成'
+ : isFailed ? `${activeStageName}执行失败`
+ : '等待启动后台任务';
+ const progressWidth = task
+ ? `${Math.max(8, ((stageIndex + (isRunning ? 0.5 : 1)) / STAGES.length) * 100)}%`
+ : '0%';
+
+ return (
+ <>
+
+
+
+
+ {STAGES.map((stage) => {
+ const stageState = task?.stage_states?.[stage.id] || DEFAULT_STAGE_STATES[stage.id];
+ const isActive = stageState === 'running';
+ const isPast = stageState === 'completed' || stageState === 'skipped';
+ const isErrored = stageState === 'failed';
+
+ return (
+
+
+ {isPast && !isActive && !isErrored ? : getStageIndex(stage.id) + 1}
+
+
+ {stage.name}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
{progressLabel}
+ {task && (
+
+ 任务 ID: {task.task_id.slice(0, 8)}
+
+ )}
+
+
+
+
+ >
+ );
+}
+
+function getStageName(stageId) {
+ const stage = STAGES.find((s) => s.id === stageId);
+ return stage?.name || stageId;
+}
diff --git a/frontend/src/hooks/useTaskRunner.js b/frontend/src/hooks/useTaskRunner.js
new file mode 100644
index 0000000..5ff8533
--- /dev/null
+++ b/frontend/src/hooks/useTaskRunner.js
@@ -0,0 +1,31 @@
+// frontend/src/hooks/useTaskRunner.js
+import { useState, useCallback } from 'react';
+import { runTask } from '../api/tasks';
+
+export default function useTaskRunner({ hydrateTask }) {
+ const [isStarting, setIsStarting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ const handleStart = useCallback(async (canStart) => {
+ if (!canStart) {
+ window.alert('请先在设置中配置目录!');
+ return;
+ }
+ setIsStarting(true);
+ setErrorMessage('');
+ try {
+ const response = await runTask();
+ await hydrateTask(response.task_id, true);
+ } catch (error) {
+ if (error.status === 409 && error.taskId) {
+ await hydrateTask(error.taskId, true);
+ } else {
+ setErrorMessage(error.message || '启动失败');
+ }
+ } finally {
+ setIsStarting(false);
+ }
+ }, [hydrateTask]);
+
+ return { isStarting, errorMessage, setErrorMessage, handleStart };
+}
diff --git a/frontend/src/hooks/useTaskStream.js b/frontend/src/hooks/useTaskStream.js
new file mode 100644
index 0000000..48c40d1
--- /dev/null
+++ b/frontend/src/hooks/useTaskStream.js
@@ -0,0 +1,177 @@
+// frontend/src/hooks/useTaskStream.js
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { createTaskStream, fetchCurrentTask, fetchTask, fetchTaskItems } from '../api/tasks';
+import {
+ DEFAULT_STAGE_STATES,
+ extractLatestItemPath, extractItemsFromEvent, mergeById
+} from '../utils/workbench';
+
+export default function useTaskStream() {
+ const [task, setTask] = useState(null);
+ const [items, setItems] = useState([]);
+ const [logs, setLogs] = useState([]);
+ const [latestFile, setLatestFile] = useState('-');
+ const [hasMoreLogs, setHasMoreLogs] = useState(false);
+ const [latestLogId, setLatestLogId] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [connState, setConnState] = useState('polling');
+
+ const streamRef = useRef(null);
+
+ const closeStream = useCallback(() => {
+ if (streamRef.current) {
+ streamRef.current.close();
+ streamRef.current = null;
+ }
+ }, []);
+
+ const openStream = useCallback((taskId) => {
+ closeStream();
+ const stream = createTaskStream(taskId);
+ streamRef.current = stream;
+
+ stream.onopen = () => setConnState('connected');
+
+ stream.onmessage = (event) => {
+ const payload = JSON.parse(event.data);
+ handleStreamEvent(payload);
+ };
+
+ stream.onerror = () => setConnState('polling');
+ stream.onclose = () => setConnState('polling');
+ }, [closeStream]);
+
+ async function handleStreamEvent(event) {
+ if (event.type === 'task.snapshot') {
+ const snapshotTask = event.data.task;
+ setTask(snapshotTask);
+ setLogs(event.data.recent_logs || []);
+ setHasMoreLogs(event.data.has_more_logs || false);
+ setLatestLogId(event.data.latest_log_id || null);
+ const snapshotLatestPath = extractLatestItemPath(event.data.recent_logs || []);
+ if (snapshotLatestPath) setLatestFile(snapshotLatestPath);
+ return;
+ }
+
+ const eventItems = extractItemsFromEvent(event);
+ if (eventItems.length > 0) {
+ setItems((currentItems) => mergeById(currentItems, eventItems));
+ setLatestFile((currentLatest) => {
+ const newPath = eventItems.at(-1)?.current_file_path || eventItems.at(-1)?.relative_path;
+ return newPath || currentLatest;
+ });
+ }
+
+ if (event.type === 'log.appended' && event.data?.log) {
+ setLogs((currentLogs) => mergeById(currentLogs, [event.data.log]));
+ setLatestLogId(event.data.log.id);
+ const eventItemPath = extractLatestItemPath([event.data.log]);
+ if (eventItemPath) setLatestFile(eventItemPath);
+ }
+
+ if (event.type === 'task.started') {
+ setTask((currentTask) =>
+ currentTask ? {
+ ...currentTask,
+ status: event.data.status,
+ current_stage: event.data.current_stage || 'scan',
+ stage_states: { ...(currentTask.stage_states || DEFAULT_STAGE_STATES), scan: 'running' }
+ } : currentTask
+ );
+ return;
+ }
+
+ if (event.type === 'stage.started') {
+ setTask((currentTask) =>
+ currentTask ? {
+ ...currentTask,
+ current_stage: event.stage,
+ stage_states: { ...(currentTask.stage_states || DEFAULT_STAGE_STATES), [event.stage]: 'running' }
+ } : currentTask
+ );
+ return;
+ }
+
+ if (
+ event.type === 'scan.progress' || event.type === 'preprocess.progress'
+ || event.type === 'match.progress' || event.type === 'dedupe.progress'
+ || event.type === 'organize.progress'
+ ) {
+ setTask((currentTask) =>
+ currentTask ? {
+ ...currentTask,
+ current_stage: event.stage,
+ status: 'running',
+ stats: event.data.stats
+ } : currentTask
+ );
+ return;
+ }
+
+ if (event.type === 'stage.completed') {
+ setTask((currentTask) =>
+ currentTask ? {
+ ...currentTask,
+ stage_states: { ...(currentTask.stage_states || DEFAULT_STAGE_STATES), [event.stage]: 'completed' },
+ stats: event.data.stats || currentTask.stats
+ } : currentTask
+ );
+ return;
+ }
+
+ if (event.type === 'task.completed' || event.type === 'task.failed') {
+ try {
+ const response = await fetchTask(event.task_id);
+ setTask(response.task);
+ } catch (err) {
+ console.error('Failed to refresh task summary', err);
+ }
+ }
+ }
+
+ async function hydrateTask(taskId, connectStream = true) {
+ const [taskResponse, itemResponse] = await Promise.all([
+ fetchTask(taskId),
+ fetchTaskItems(taskId, { pageSize: 200 })
+ ]);
+ setTask(taskResponse.task);
+ setItems(itemResponse.items);
+ const lastItem = itemResponse.items.at(-1);
+ setLatestFile(lastItem?.current_file_path || lastItem?.relative_path || lastItem?.original_path || '-');
+ if (connectStream) openStream(taskId);
+ }
+
+ const loadCurrentTask = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetchCurrentTask();
+ if (!response.task) {
+ setTask(null); setItems([]); setLogs([]);
+ setLatestFile('-'); setHasMoreLogs(false);
+ setLatestLogId(null); closeStream();
+ setConnState('polling');
+ return;
+ }
+ await hydrateTask(response.task.task_id, true);
+ } catch (error) {
+ console.error('Failed to load current task', error);
+ closeStream();
+ setConnState('polling');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [closeStream, openStream]);
+
+ useEffect(() => {
+ loadCurrentTask();
+ return () => { closeStream(); setConnState('polling'); };
+ }, []);
+
+ return {
+ task, items, logs, latestFile, hasMoreLogs, latestLogId,
+ isLoading, connState,
+ setTask, setItems, setLogs,
+ loadCurrentTask, hydrateTask,
+ setConnState
+ };
+}
diff --git a/frontend/src/utils/workbench.js b/frontend/src/utils/workbench.js
new file mode 100644
index 0000000..4d8263b
--- /dev/null
+++ b/frontend/src/utils/workbench.js
@@ -0,0 +1,130 @@
+// frontend/src/utils/workbench.js
+
+export const DEFAULT_STAGE_STATES = {
+ scan: 'pending',
+ preprocess: 'pending',
+ match: 'pending',
+ dedupe: 'pending',
+ organize: 'pending',
+ complete: 'pending'
+};
+
+export const EMPTY_SCAN_STATS = {
+ total_found: 0, queued: 0, skipped_locked: 0,
+ skipped_invalid: 0, ignored_non_audio: 0
+};
+
+export const EMPTY_PREPROCESS_STATS = {
+ input_items: 0, output_items: 0, split_parents: 0,
+ generated_children: 0, converted_items: 0, metadata_snapshots: 0,
+ fingerprints_ok: 0, fingerprints_failed: 0,
+ failed_items: 0, warning_items: 0
+};
+
+export const EMPTY_MATCH_STATS = {
+ input_items: 0, matched_authoritative: 0, matched_fallback: 0,
+ low_score: 0, not_found: 0, provider_warnings: 0, failed_items: 0
+};
+
+export const EMPTY_DEDUPE_STATS = {
+ input_items: 0, library_candidates: 0, batch_duplicates: 0,
+ library_duplicates: 0, replaced_library_items: 0,
+ kept_items: 0, failed_items: 0
+};
+
+export const EMPTY_ORGANIZE_STATS = {
+ input_items: 0, moved_items: 0, renamed_items: 0,
+ collision_resolved: 0, trashed_items: 0, failed_items: 0
+};
+
+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 }
+ ],
+ preprocess: [
+ { key: 'input_items', label: '输入项目', color: 'white' },
+ { key: 'output_items', label: '有效输出', color: 'emerald' },
+ { key: 'split_parents', label: '切轨父项', color: 'blue' },
+ { key: 'generated_children', label: '生成子轨', color: 'cyan' },
+ { key: 'converted_items', label: '已转码', color: 'indigo' },
+ { key: 'metadata_snapshots', label: '元数据快照', color: 'slate' },
+ { key: 'fingerprints_ok', label: '指纹成功', color: 'emerald' },
+ { key: 'fingerprints_failed', label: '指纹警告', color: 'amber' },
+ { key: 'warning_items', label: '项目警告', color: 'amber' },
+ { key: 'failed_items', label: '项目失败', color: 'rose', error: true }
+ ],
+ match: [
+ { key: 'input_items', label: '进入匹配', color: 'white' },
+ { key: 'matched_authoritative', label: '权威命中', color: 'emerald' },
+ { key: 'matched_fallback', label: 'Fallback 命中', color: 'sky' },
+ { key: 'low_score', label: '低分待审', color: 'amber' },
+ { key: 'not_found', label: '未命中', color: 'slate' },
+ { key: 'provider_warnings', label: 'Provider 告警', color: 'orange' },
+ { key: 'failed_items', label: '请求失败', color: 'rose', error: true }
+ ],
+ dedupe: [
+ { key: 'input_items', label: '进入去重', color: 'white' },
+ { key: 'library_candidates', label: '库内候选', color: 'slate' },
+ { key: 'batch_duplicates', label: '批次重复', color: 'amber' },
+ { key: 'library_duplicates', label: '库内重复', color: 'amber' },
+ { key: 'replaced_library_items', label: '替换旧库', color: 'cyan' },
+ { key: 'kept_items', label: '保留项目', color: 'emerald' },
+ { key: 'failed_items', label: '处理失败', color: 'rose', error: true }
+ ],
+ organize: [
+ { key: 'input_items', label: '进入入库', color: 'white' },
+ { key: 'moved_items', label: '移动完成', color: 'emerald' },
+ { key: 'renamed_items', label: '重命名', color: 'cyan' },
+ { key: 'collision_resolved', label: '冲突化解', color: 'sky' },
+ { key: 'trashed_items', label: '失败入桶', color: 'amber' },
+ { key: 'failed_items', label: '处理失败', color: 'rose', error: true }
+ ]
+};
+
+export const STAGE_EXTRA_FIELDS = {
+ scan: { key: 'ignored_non_audio', label: '静默忽略非音频', color: 'slate' }
+};
+
+// --- Utility functions ---
+
+export function formatLogTime(value) {
+ if (!value) return '--:--:--';
+ try {
+ return new Date(value).toLocaleTimeString('zh-CN', { hour12: false });
+ } catch {
+ return String(value);
+ }
+}
+
+export function resolveItemDisplayPath(item) {
+ if (!item) return null;
+ return item.current_file_path || item.relative_path || item.original_path;
+}
+
+export function extractLatestItemPath(logs) {
+ for (let i = logs.length - 1; i >= 0; i--) {
+ const path = resolveItemDisplayPath(logs[i]);
+ if (path) return path;
+ }
+ return null;
+}
+
+export function extractItemsFromEvent(event) {
+ if (event.type === 'scan.progress' || event.type === 'preprocess.progress'
+ || event.type === 'match.progress' || event.type === 'dedupe.progress'
+ || event.type === 'organize.progress') {
+ return event.data?.updated_items || event.data?.items || [];
+ }
+ if (event.data?.items) return event.data.items;
+ if (event.data?.item) return [event.data.item];
+ return [];
+}
+
+export function mergeById(existing, incoming) {
+ const map = new Map(existing.map((item) => [item.id, item]));
+ incoming.forEach((item) => { map.set(item.id, { ...map.get(item.id), ...item }); });
+ return Array.from(map.values());
+}