From 998658da7b61c5a35d78c930d47c41672ab9e01d Mon Sep 17 00:00:00 2001 From: liumangmang Date: Thu, 7 May 2026 23:26:05 +0800 Subject: [PATCH] refactor: rewrite WorkbenchPage as lightweight container with hooks Replaced ~800 line monolithic WorkbenchPage.jsx with ~80 line container delegating to useTaskStream (WebSocket events), useTaskRunner (start logic), TaskControlPanel, TaskProgressBar, StageStatsPanel (config-driven 40+ StatCards), TaskInfoPanel, TaskFileList, and TaskLogStream. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/WorkbenchPage.jsx | 1077 ++------------------------ 1 file changed, 57 insertions(+), 1020 deletions(-) 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 ( -
-
{label}
-
{value}
-
- ); -} - -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(); -}