diff --git a/frontend/src/pages/WorkbenchPage.jsx b/frontend/src/pages/WorkbenchPage.jsx
index e9c6cdb..6cc7ef2 100644
--- a/frontend/src/pages/WorkbenchPage.jsx
+++ b/frontend/src/pages/WorkbenchPage.jsx
@@ -1,1060 +1,97 @@
-import { useEffect, useRef, useState } from 'react';
+// frontend/src/pages/WorkbenchPage.jsx
+import { useRef } from 'react';
+import { deriveTaskState } from '../constants';
import {
- Activity,
- AlertTriangle,
- Check,
- CheckCircle2,
- Edit3,
- FileSearch,
- Folder,
- ListChecks,
- Play,
- Settings
-} from 'lucide-react';
-import { useNavigate } from 'react-router-dom';
-import {
- createTaskStream,
- fetchCurrentTask,
- fetchTask,
- fetchTaskItems,
- runTask
-} from '../api/tasks';
-import { deriveTaskState, getStageIndex, STAGES } from '../constants';
+ EMPTY_SCAN_STATS, EMPTY_PREPROCESS_STATS, EMPTY_MATCH_STATS,
+ EMPTY_DEDUPE_STATS, EMPTY_ORGANIZE_STATS
+} from '../utils/workbench';
+import useTaskStream from '../hooks/useTaskStream';
+import useTaskRunner from '../hooks/useTaskRunner';
+import TaskControlPanel from '../components/workbench/TaskControlPanel';
+import TaskProgressBar from '../components/workbench/TaskProgressBar';
+import StageStatsPanel from '../components/workbench/StageStatsPanel';
+import TaskInfoPanel from '../components/workbench/TaskInfoPanel';
+import TaskFileList from '../components/workbench/TaskFileList';
+import TaskLogStream from '../components/workbench/TaskLogStream';
-const DEFAULT_STAGE_STATES = {
- scan: 'pending',
- preprocess: 'pending',
- match: 'pending',
- dedupe: 'pending',
- organize: 'pending',
- complete: 'pending'
-};
-
-const EMPTY_SCAN_STATS = {
- total_found: 0,
- queued: 0,
- skipped_locked: 0,
- skipped_invalid: 0,
- ignored_non_audio: 0
-};
-
-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
-};
-
-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
-};
-
-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
-};
-
-const EMPTY_ORGANIZE_STATS = {
- input_items: 0,
- moved_items: 0,
- renamed_items: 0,
- collision_resolved: 0,
- trashed_items: 0,
- failed_items: 0
-};
-
-export default function WorkbenchPage({ config, setTaskState, setConnState }) {
- const navigate = useNavigate();
+export default function WorkbenchPage({ config, setTaskState }) {
const logsEndRef = useRef(null);
- const streamRef = useRef(null);
- const currentTaskIdRef = useRef(null);
- 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 [isStarting, setIsStarting] = useState(false);
- const [errorMessage, setErrorMessage] = useState('');
- useEffect(() => {
- loadCurrentTask();
+ const {
+ task, items, logs, latestFile, hasMoreLogs, latestLogId,
+ isLoading,
+ hydrateTask
+ } = useTaskStream();
- return () => {
- closeStream();
- setConnState('polling');
- };
- }, []);
-
- useEffect(() => {
- if (logsEndRef.current) {
- logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
- }
- }, [logs]);
-
- useEffect(() => {
- setTaskState(deriveTaskState(config, task));
- }, [config, setTaskState, task]);
+ const { isStarting, errorMessage, handleStart } = useTaskRunner({ hydrateTask });
const isRunning = task?.status === 'pending' || task?.status === 'running';
const isCompleted = task?.status === 'completed';
const isFailed = task?.status === 'failed';
const canStart = Boolean(config.input?.trim() && config.output?.trim() && config.trash?.trim());
+
const scanStats = task?.stats?.scan || EMPTY_SCAN_STATS;
const preprocessStats = task?.stats?.preprocess || EMPTY_PREPROCESS_STATS;
const matchStats = task?.stats?.match || EMPTY_MATCH_STATS;
const dedupeStats = task?.stats?.dedupe || EMPTY_DEDUPE_STATS;
const organizeStats = task?.stats?.organize || EMPTY_ORGANIZE_STATS;
- async function loadCurrentTask() {
- setIsLoading(true);
- setErrorMessage('');
-
- 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) {
- setErrorMessage(error.message);
- closeStream();
- setConnState('polling');
- } finally {
- setIsLoading(false);
- }
- }
-
- async function hydrateTask(taskId, connectStream) {
- currentTaskIdRef.current = taskId;
- const [taskResponse, itemResponse] = await Promise.all([
- fetchTask(taskId),
- fetchTaskItems(taskId, { pageSize: 200 })
- ]);
-
- setTask(taskResponse.task);
- setItems(itemResponse.items);
- setLatestFile(resolveItemDisplayPath(itemResponse.items.at(-1)) || '-');
-
- if (connectStream) {
- openStream(taskId);
- }
- }
-
- function openStream(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');
- };
- }
-
- function closeStream() {
- if (streamRef.current) {
- streamRef.current.close();
- streamRef.current = null;
- }
- }
-
- 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) => resolveItemDisplayPath(eventItems.at(-1)) || 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') {
- void refreshTaskSummary(event.task_id);
- }
- }
-
- async function refreshTaskSummary(taskId) {
- try {
- const response = await fetchTask(taskId);
- setTask(response.task);
- setTaskState(deriveTaskState(config, response.task));
- } catch (error) {
- setErrorMessage(error.message);
- }
- }
-
- async function handleStart() {
- if (!canStart) {
- window.alert('请先在设置中配置目录!');
- return;
- }
-
- setIsStarting(true);
- setErrorMessage('');
-
- try {
- const response = await runTask();
- setItems([]);
- setLogs([]);
- setLatestFile('-');
- setHasMoreLogs(false);
- setLatestLogId(null);
- 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);
- }
- }
-
- const stageIndex = getStageIndex(task?.current_stage || 'scan');
- 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 (
+ {/* Left Column */}
-
-
-
-
- 处理编排
-
-
-
-
-
-
-
- {!canStart && (
-
-
-
- )}
-
-
-
-
- {isRunning ? (
-
-
-
后台任务执行中
-
{activeStageName}正在后台执行,实时日志、去重决策与入库路径会持续刷新。
-
- ) : isFailed ? (
-
-
-
后台任务失败
-
{task?.error_message || '请查看任务记录流排查原因。'}
-
-
- ) : (
-
- {isCompleted && (
-
-
-
- )}
-
- {isCompleted ? '五阶段任务已完成' : '准备启动后台任务'}
-
-
-
- )}
-
- {errorMessage && (
-
- {errorMessage}
-
- )}
-
+
+ {/* Right Column */}
-
-
-
-
- {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}
-
-
- );
- })}
-
-
-
-
-
-
-
- 当前状态
-
-
- {latestFile}
-
-
-
-
{progressLabel}
- {task && (
-
- 任务 ID: {task.task_id.slice(0, 8)}
-
- )}
-
-
-
-
+
+ {/* Stats + Info Sidebar */}
阶段统计
-
-
- Scan
-
-
-
-
-
-
-
-
-
静默忽略非音频
-
{scanStats.ignored_non_audio}
-
-
-
-
-
- Preprocess
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Match
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Dedupe
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Organize
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
- 当前任务
-
-
-
-
-
-
-
-
-
-
+
-
-
- 任务文件
-
-
-
- {isLoading ? (
-
正在加载任务数据...
- ) : items.length === 0 ? (
-
当前暂无任务文件
- ) : (
- items.map((item) => (
-
-
-
-
{item.relative_path}
-
- {item.local_cover ? '已挂载封面' : '无本地封面'} / {item.local_lyric ? '已挂载歌词' : '无本地歌词'}
-
- {item.current_file_path && item.current_file_path !== item.original_path && (
-
- 当前文件: {item.current_file_path}
-
- )}
- {item.preprocess_message && (
-
- {item.preprocess_message}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- ))
- )}
-
-
-
-
-
-
- 任务记录流
-
-
- {logs.length === 0 ? (
-
等待任务启动...
- ) : (
- logs.map((log) => (
-
- [{formatLogTime(log.created_at)}]
-
- {log.message}
-
-
- ))
- )}
-
-
-
+ {/* File List + Log Stream */}
+
+
);
}
-
-function DirectoryField({ label, value, missingText }) {
- return (
-
-
-
- {value || missingText}
-
-
- );
-}
-
-function StatCard({ label, value, labelClass = 'text-slate-500', valueClass, error = false }) {
- return (
-
- );
-}
-
-function InfoRow({ label, value }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-function StatusBadge({ status, variant = 'scan' }) {
- const scanStatusMap = {
- 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 scanLabelMap = {
- queued: '已入队',
- skipped_locked: '已跳过',
- invalid: '无效'
- };
- const preprocessStatusMap = {
- 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 preprocessLabelMap = {
- pending: '待预处理',
- skipped: '不处理',
- running: '处理中',
- completed: '已完成',
- warning: '有警告',
- failed: '失败',
- replaced_by_split: '已切轨'
- };
- const matchStatusMap = {
- pending: 'border-slate-700 bg-slate-800 text-slate-300',
- not_entered: 'border-slate-700 bg-slate-800 text-slate-500',
- running: 'border-blue-500/20 bg-blue-500/10 text-blue-300',
- matched: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400',
- matched_fallback: 'border-sky-500/20 bg-sky-500/10 text-sky-300',
- low_score: 'border-amber-500/20 bg-amber-500/10 text-amber-400',
- not_found: 'border-slate-700 bg-slate-900 text-slate-300',
- failed: 'border-rose-500/20 bg-rose-500/10 text-rose-400'
- };
- const matchLabelMap = {
- pending: '待匹配',
- not_entered: '未进入匹配',
- running: '匹配中',
- matched: '已匹配',
- matched_fallback: 'Fallback',
- low_score: '低分待审',
- not_found: '未命中',
- failed: '匹配失败'
- };
- const dedupeStatusMap = {
- pending: 'border-slate-700 bg-slate-800 text-slate-300',
- not_entered: 'border-slate-700 bg-slate-800 text-slate-500',
- running: 'border-blue-500/20 bg-blue-500/10 text-blue-300',
- unique: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400',
- duplicate_trashed: 'border-amber-500/20 bg-amber-500/10 text-amber-400',
- duplicate_replaced: 'border-cyan-500/20 bg-cyan-500/10 text-cyan-300',
- failed: 'border-rose-500/20 bg-rose-500/10 text-rose-400'
- };
- const dedupeLabelMap = {
- pending: '待去重',
- not_entered: '未进入去重',
- running: '去重中',
- unique: '已保留',
- duplicate_trashed: '重复淘汰',
- duplicate_replaced: '替换旧库',
- failed: '去重失败'
- };
- const organizeStatusMap = {
- pending: 'border-slate-700 bg-slate-800 text-slate-300',
- not_entered: 'border-slate-700 bg-slate-800 text-slate-500',
- running: 'border-blue-500/20 bg-blue-500/10 text-blue-300',
- organized: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400',
- trashed: 'border-amber-500/20 bg-amber-500/10 text-amber-400',
- failed: 'border-rose-500/20 bg-rose-500/10 text-rose-400'
- };
- const organizeLabelMap = {
- pending: '待入库',
- not_entered: '未入库',
- running: '入库中',
- organized: '已入库',
- trashed: '失败入桶',
- failed: '入库失败'
- };
-
- const statusMapByVariant = {
- scan: scanStatusMap,
- preprocess: preprocessStatusMap,
- match: matchStatusMap,
- dedupe: dedupeStatusMap,
- organize: organizeStatusMap
- };
- const labelMapByVariant = {
- scan: scanLabelMap,
- preprocess: preprocessLabelMap,
- match: matchLabelMap,
- dedupe: dedupeLabelMap,
- organize: organizeLabelMap
- };
- const statusMap = statusMapByVariant[variant] || scanStatusMap;
- const labelMap = labelMapByVariant[variant] || scanLabelMap;
-
- return (
-
- {labelMap[status] || status}
-
- );
-}
-
-function MatchSummary({ item }) {
- const displayStatus = getMatchDisplayStatus(item);
- const matchedMetadata = item.matched_metadata_json;
-
- if (displayStatus === 'not_entered') {
- return (
-
- 当前文件未进入匹配阶段。
-
- );
- }
-
- if (displayStatus === 'pending') {
- return (
-
- 已完成预处理,等待进入匹配。
-
- );
- }
-
- return (
-
- {(matchedMetadata?.title || matchedMetadata?.artist || matchedMetadata?.album) && (
-
- {matchedMetadata?.title || '未知标题'}
- {(matchedMetadata?.artist || matchedMetadata?.album) && ' · '}
- {[matchedMetadata?.artist, matchedMetadata?.album].filter(Boolean).join(' / ')}
-
- )}
- {(item.match_source || item.match_confidence != null) && (
-
- 来源: {item.match_source || '-'}
- {item.match_is_authoritative ? ' / 权威' : item.match_source ? ' / Fallback' : ''}
- {item.match_confidence != null ? ` / ${Number(item.match_confidence).toFixed(1)} 分` : ''}
-
- )}
- {item.match_message && (
-
- {item.match_message}
-
- )}
-
- );
-}
-
-function PostprocessSummary({ item }) {
- const dedupeStatus = getDedupeDisplayStatus(item);
- const organizeStatus = getOrganizeDisplayStatus(item);
-
- if (dedupeStatus === 'not_entered' && organizeStatus === 'not_entered') {
- return null;
- }
-
- return (
-
- {item.duplicate_of_path && (
-
- 重复来源: {item.duplicate_of_path}
-
- )}
- {item.dedupe_message && (
-
- 去重: {item.dedupe_message}
-
- )}
- {item.library_relative_path && (
-
- 入库路径: {item.library_relative_path}
-
- )}
- {item.trash_file_path && (
-
- 回收站: {item.trash_file_path}
-
- )}
- {item.organize_message && (
-
- 入库: {item.organize_message}
-
- )}
-
- );
-}
-
-function mergeById(currentItems, nextItems) {
- const itemMap = new Map(currentItems.map((item) => [item.id, item]));
-
- nextItems.forEach((item) => {
- itemMap.set(item.id, item);
- });
-
- return Array.from(itemMap.values()).sort((left, right) => left.id - right.id);
-}
-
-function extractLatestItemPath(logs) {
- for (let index = logs.length - 1; index >= 0; index -= 1) {
- const children = logs[index]?.payload?.children || logs[index]?.payload?.items || [];
- const itemPath =
- resolveItemDisplayPath(logs[index]?.payload?.item) ||
- resolveItemDisplayPath(children[children.length - 1]);
- if (itemPath) {
- return itemPath;
- }
- }
-
- return '';
-}
-
-function extractItemsFromEvent(event) {
- const items = [];
-
- if (event.data?.item) {
- items.push(event.data.item);
- }
-
- if (Array.isArray(event.data?.items)) {
- items.push(...event.data.items);
- }
-
- if (Array.isArray(event.data?.children)) {
- items.push(...event.data.children);
- }
-
- return items;
-}
-
-function getMatchDisplayStatus(item) {
- const canEnterMatch =
- item.scan_status === 'queued'
- && ['completed', 'warning'].includes(item.preprocess_status);
-
- if (!canEnterMatch) {
- return 'not_entered';
- }
-
- return item.match_status || 'pending';
-}
-
-function getStageName(stageId) {
- return STAGES.find((stage) => stage.id === stageId)?.name || stageId;
-}
-
-function resolveItemDisplayPath(item) {
- if (!item) {
- return '';
- }
-
- return item.library_relative_path || item.trash_file_path || item.relative_path || item.current_file_path || '';
-}
-
-function getDedupeDisplayStatus(item) {
- const canEnterDedupe = ['matched', 'matched_fallback'].includes(item.match_status);
- if (!canEnterDedupe) {
- return 'not_entered';
- }
- return item.dedupe_status || 'pending';
-}
-
-function getOrganizeDisplayStatus(item) {
- const canEnterOrganize = ['unique', 'duplicate_replaced'].includes(item.dedupe_status);
- if (!canEnterOrganize) {
- return 'not_entered';
- }
- return item.organize_status || 'pending';
-}
-
-function formatLogTime(timestamp) {
- if (!timestamp) {
- return '--:--:--';
- }
-
- const date = new Date(timestamp);
- if (Number.isNaN(date.getTime())) {
- return timestamp;
- }
-
- return date.toLocaleTimeString();
-}