# WorkbenchPage 重构 — 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将 WorkbenchPage.jsx 从 ~800 行单体组件重构为 6 个聚焦组件 + 2 个自定义 hooks,消除 40+ StatCard 重复渲染,继承深色专业主题。 **Architecture:** 按 Phase 分层推进 — 先提取共享常量,再创建纯逻辑层 hooks(WebSocket 事件处理 + 任务启动),然后拆分组件(统计面板消除最大重复),最后重写容器。数据通过 props 向下流动,不使用 Context。 **Tech Stack:** React 18 + Vite + Tailwind CSS + lucide-react icons,无后端改动 **Spec:** [2026-05-07-workbench-refactor-design.md](../specs/2026-05-07-workbench-refactor-design.md) --- ## 文件结构总览 ``` frontend/src/ ├── pages/ │ └── WorkbenchPage.jsx # Modify: 精简为容器组件 (~120行) ├── components/workbench/ # Create directory │ ├── TaskControlPanel.jsx # Create │ ├── TaskProgressBar.jsx # Create │ ├── StageStatsPanel.jsx # Create │ ├── TaskInfoPanel.jsx # Create │ ├── TaskFileList.jsx # Create │ └── TaskLogStream.jsx # Create ├── hooks/ │ ├── useTaskStream.js # Create │ └── useTaskRunner.js # Create └── utils/ └── workbench.js # Create (共享常量) ``` --- ## Phase 1: 共享常量提取 ### Task 1.1: 创建 utils/workbench.js **Files:** - Create: `frontend/src/utils/workbench.js` - Modify: `frontend/src/pages/WorkbenchPage.jsx` (import from utils) - [ ] **Step 1: 创建 utils/workbench.js** ```javascript // 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 }; // 阶段统计配置 — StageStatsPanel 根据 stageName 查找配置自动生成 StatCard 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 } ] }; // 额外展示的字段(不在 grid 中,如 ignored_non_audio) export const STAGE_EXTRA_FIELDS = { scan: { key: 'ignored_non_audio', label: '静默忽略非音频', color: 'slate' } }; 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()); } ``` - [ ] **Step 2: 更新 WorkbenchPage.jsx 导入** 在 WorkbenchPage.jsx 第 23-31 行之后添加导入: ```javascript import { DEFAULT_STAGE_STATES, EMPTY_SCAN_STATS, EMPTY_PREPROCESS_STATS, EMPTY_MATCH_STATS, EMPTY_DEDUPE_STATS, EMPTY_ORGANIZE_STATS, STAGE_STATS_CONFIG, STAGE_EXTRA_FIELDS, formatLogTime, resolveItemDisplayPath, extractLatestItemPath, extractItemsFromEvent, mergeById } from '../utils/workbench'; ``` 原文件中第 24-81 行的内联常量定义保持不变(后续 Phase 容器重写时才移除)。 - [ ] **Step 3: 验证构建** ```bash cd frontend && npm run build ``` Expected: PASS - [ ] **Step 4: Commit** ```bash git add frontend/src/utils/workbench.js frontend/src/pages/WorkbenchPage.jsx git commit -m "feat: extract workbench constants and utilities to utils/workbench.js Co-Authored-By: Claude Opus 4.6 " ``` --- ## Phase 2: useTaskStream Hook ### Task 2.1: 创建 useTaskStream hook **Files:** - Create: `frontend/src/hooks/useTaskStream.js` - [ ] **Step 1: 编写 useTaskStream** ```javascript // 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 currentTaskIdRef = 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) { currentTaskIdRef.current = taskId; 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 }; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/hooks/useTaskStream.js git commit -m "feat: add useTaskStream hook for WebSocket event handling Co-Authored-By: Claude Opus 4.6 " ``` --- ## Phase 3: useTaskRunner Hook ### Task 3.1: 创建 useTaskRunner hook **Files:** - Create: `frontend/src/hooks/useTaskRunner.js` - [ ] **Step 1: 编写 useTaskRunner** ```javascript // 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 }; } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/hooks/useTaskRunner.js git commit -m "feat: add useTaskRunner hook for task start logic Co-Authored-By: Claude Opus 4.6 " ``` --- ## Phase 4: 展示组件 ### Task 4.1: 创建 StageStatsPanel(消除最大重复) **Files:** - Create: `frontend/src/components/workbench/StageStatsPanel.jsx` - [ ] **Step 1: 编写 StageStatsPanel** ```javascript // 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 (
{label}
{value}
); } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/workbench/StageStatsPanel.jsx git commit -m "feat: add StageStatsPanel to eliminate 40+ repeated StatCards Co-Authored-By: Claude Opus 4.6 " ``` ### Task 4.2: 创建 TaskControlPanel、TaskProgressBar、TaskInfoPanel、TaskFileList、TaskLogStream **Files:** - Create: `frontend/src/components/workbench/TaskControlPanel.jsx` - Create: `frontend/src/components/workbench/TaskProgressBar.jsx` - Create: `frontend/src/components/workbench/TaskInfoPanel.jsx` - Create: `frontend/src/components/workbench/TaskFileList.jsx` - Create: `frontend/src/components/workbench/TaskLogStream.jsx` - [ ] **Step 1: TaskControlPanel.jsx** ```javascript // 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 }) { const navigate = useNavigate(); return ( <>

处理编排

{!canStart && (
)}
{isRunning ? (

后台任务执行中

{activeStageName}正在后台执行,实时日志、去重决策与入库路径会持续刷新。

) : isFailed ? (

后台任务失败

{/* error_message from task */}

) : (
{isCompleted && (
)}

{isCompleted ? '五阶段任务已完成' : '准备启动后台任务'}

)} {errorMessage && (
{errorMessage}
)}
); } ``` - [ ] **Step 2: TaskProgressBar.jsx** ```javascript // 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}
); })}
当前状态
{latestFile}
{progressLabel}
{task && (
任务 ID: {task.task_id.slice(0, 8)}
)}
); } function getStageName(stageId) { const stage = STAGES.find((s) => s.id === stageId); return stage?.name || stageId; } ``` - [ ] **Step 3: TaskInfoPanel.jsx** ```javascript // frontend/src/components/workbench/TaskInfoPanel.jsx function InfoRow({ label, value }) { return (
{label} {value}
); } export default function TaskInfoPanel({ task, latestLogId, hasMoreLogs }) { return (

当前任务

); } ``` - [ ] **Step 4: TaskFileList.jsx** ```javascript // frontend/src/components/workbench/TaskFileList.jsx import { useState } from 'react'; 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}
)}
)) )}
); } ``` - [ ] **Step 5: TaskLogStream.jsx** ```javascript // 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}
)) )}
); } ``` - [ ] **Step 6: Commit** ```bash git add frontend/src/components/workbench/ git commit -m "feat: add TaskControlPanel, TaskProgressBar, TaskInfoPanel, TaskFileList, TaskLogStream Co-Authored-By: Claude Opus 4.6 " ``` --- ## Phase 5: 重写 WorkbenchPage 容器 ### Task 5.1: 重写 WorkbenchPage.jsx **Files:** - Modify: `frontend/src/pages/WorkbenchPage.jsx` - [ ] **Step 1: 用新的 hooks 和组件重写 WorkbenchPage.jsx** ```javascript // frontend/src/pages/WorkbenchPage.jsx import { useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { deriveTaskState } from '../constants'; import { 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'; export default function WorkbenchPage({ config, setTaskState }) { const navigate = useNavigate(); const logsEndRef = useRef(null); const { task, items, logs, latestFile, hasMoreLogs, latestLogId, isLoading, hydrateTask, setConnState } = useTaskStream(); 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; return (
{/* Left Column */}
{/* Right Column */}
{/* Stats + Info Sidebar */}

阶段统计

{/* File List + Log Stream */}
); } ``` - [ ] **Step 2: 验证构建** ```bash cd frontend && npm run build ``` Expected: PASS - [ ] **Step 3: Commit** ```bash git add frontend/src/pages/WorkbenchPage.jsx git commit -m "refactor: rewrite WorkbenchPage as lightweight container with hooks Co-Authored-By: Claude Opus 4.6 " ``` --- ## 验证清单 - [ ] `npm run build` 成功,无错误 - [ ] 任务启动按钮正常 - [ ] 5 阶段进度条正常显示 - [ ] 阶段统计面板正常(5 个阶段所有统计卡片) - [ ] 任务文件列表正常 - [ ] 实时日志流正常 - [ ] WebSocket 事件处理正常(启动/进度/完成/失败/日志追加) - [ ] 配置目录展示正常 - [ ] 任务失败/重试流程正常