Files
MusicWorkshop/docs/superpowers/plans/2026-05-07-workbench-refactor.md
T
liumangmang be3c086975 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>
2026-05-08 15:49:37 +08:00

39 KiB
Raw Blame History

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


文件结构总览

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

// 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 行之后添加导入:

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: 验证构建
cd frontend && npm run build

Expected: PASS

  • Step 4: Commit
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 <noreply@anthropic.com>"

Phase 2: useTaskStream Hook

Task 2.1: 创建 useTaskStream hook

Files:

  • Create: frontend/src/hooks/useTaskStream.js

  • Step 1: 编写 useTaskStream

// 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
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 <noreply@anthropic.com>"

Phase 3: useTaskRunner Hook

Task 3.1: 创建 useTaskRunner hook

Files:

  • Create: frontend/src/hooks/useTaskRunner.js

  • Step 1: 编写 useTaskRunner

// 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
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 <noreply@anthropic.com>"

Phase 4: 展示组件

Task 4.1: 创建 StageStatsPanel(消除最大重复)

Files:

  • Create: frontend/src/components/workbench/StageStatsPanel.jsx

  • Step 1: 编写 StageStatsPanel

// 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 (
    <div>
      <div className="mb-2 text-[10px] font-bold uppercase tracking-wider text-slate-500">
        {stageLabels[stageName] || stageName}
      </div>
      <div className="grid grid-cols-2 gap-2">
        {config.map(({ key, label, color, error }) => (
          <StatCard
            key={key}
            label={label}
            value={stats[key] ?? 0}
            color={color}
            error={error}
          />
        ))}
      </div>
      {extraField && (
        <div className="mt-3 rounded-lg border border-slate-800/50 bg-slate-950 p-3">
          <div className="mb-1 text-xs text-slate-500">{extraField.label}</div>
          <div className={`font-mono text-xl font-bold text-${extraField.color === 'slate' ? 'slate-300' : extraField.color + '-400'}`}>
            {stats[extraField.key] ?? 0}
          </div>
        </div>
      )}
    </div>
  );
}

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 (
    <div className={`rounded-lg border p-3 ${error ? 'border-rose-900/30 bg-slate-950' : 'border-slate-800/50 bg-slate-950'}`}>
      <div className={`mb-1 text-xs ${labelMap[color] || 'text-slate-500'}`}>{label}</div>
      <div className={`font-mono text-xl font-bold ${colorMap[color] || 'text-white'}`}>{value}</div>
    </div>
  );
}
  • Step 2: Commit
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 <noreply@anthropic.com>"

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

// 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 (
    <div>
      <label className="mb-1 block text-xs font-medium text-slate-400">{label}</label>
      <div className={`truncate rounded border px-3 py-2 font-mono text-sm ${
        value
          ? 'border-slate-800 bg-slate-950 text-slate-300'
          : 'border-rose-900/50 bg-rose-950/20 italic text-rose-500/70'
      }`}>
        {value || missingText}
      </div>
    </div>
  );
}

export default function TaskControlPanel({
  config, canStart, isRunning, isCompleted, isFailed,
  onStart, isStarting, errorMessage, activeStageName
}) {
  const navigate = useNavigate();

  return (
    <>
      <div className="group relative rounded-xl border border-slate-800 bg-slate-900 p-5 shadow-lg">
        <div className="mb-4 flex items-center justify-between">
          <h2 className="flex items-center text-base font-semibold text-white">
            <Folder className="mr-2 h-5 w-5 text-blue-400" />
            处理编排
          </h2>
          <button
            onClick={() => navigate('/settings')}
            className="flex items-center rounded bg-slate-800/50 px-2 py-1 text-xs text-slate-400 transition hover:bg-slate-800 hover:text-blue-400"
          >
            <Settings className="mr-1.5 h-3 w-3" />
            前往修改配置
          </button>
        </div>
        <div className="space-y-4">
          <DirectoryField label="输入目录 (待处理源)" value={config.input} missingText="⚠ 尚未配置输入目录" />
          <DirectoryField label="输出目录 (Navidrome库)" value={config.output} missingText="⚠ 尚未配置输出目录" />
          <DirectoryField label="回收站目录 (异常隔离)" value={config.trash} missingText="⚠ 尚未配置回收站目录" />
          {!canStart && (
            <div className="pt-2">
              <button
                onClick={() => navigate('/settings')}
                className="flex w-full items-center justify-center border border-blue-500/30 bg-blue-600/20 py-2 text-sm font-medium text-blue-400 transition hover:bg-blue-600/40"
              >
                <Edit3 className="mr-2 h-4 w-4" />
                点击前往填写系统配置
              </button>
            </div>
          )}
        </div>
      </div>

      <div className="flex flex-1 flex-col justify-center rounded-xl border border-slate-800 bg-slate-900 p-5 shadow-lg">
        {isRunning ? (
          <div className="w-full text-center">
            <div className="mb-4 inline-block rounded-full bg-emerald-500/10 p-3">
              <Activity className="h-10 w-10 animate-pulse text-emerald-500" />
            </div>
            <h3 className="mb-2 text-xl font-bold text-emerald-400">后台任务执行中</h3>
            <p className="text-sm text-slate-400">{activeStageName}正在后台执行实时日志去重决策与入库路径会持续刷新</p>
          </div>
        ) : isFailed ? (
          <div className="w-full text-center">
            <div className="mb-4 inline-block rounded-full bg-rose-500/10 p-3">
              <AlertTriangle className="h-10 w-10 text-rose-400" />
            </div>
            <h3 className="mb-2 text-xl font-bold text-rose-400">后台任务失败</h3>
            <p className="mb-4 text-sm text-slate-400">{/* error_message from task */}</p>
            <button onClick={() => onStart(canStart)} disabled={isStarting}
              className="flex w-full items-center justify-center rounded-lg bg-blue-600 py-3 text-sm font-bold text-white shadow-lg shadow-blue-900/20 transition hover:bg-blue-500 disabled:opacity-60">
              <Play className="mr-2 h-5 w-5" />
              {isStarting ? '重新启动中...' : '重新启动扫描'}
            </button>
          </div>
        ) : (
          <div className="w-full text-center">
            {isCompleted && (
              <div className="mb-4 inline-block rounded-full bg-slate-800 p-3">
                <CheckCircle2 className="h-10 w-10 text-emerald-400" />
              </div>
            )}
            <h3 className="mb-4 text-xl font-bold text-white">
              {isCompleted ? '五阶段任务已完成' : '准备启动后台任务'}
            </h3>
            <button onClick={() => onStart(canStart)} disabled={isStarting || !canStart}
              className="group flex h-16 w-full items-center justify-center rounded-lg bg-blue-600 text-lg font-bold text-white shadow-lg shadow-blue-900/20 transition-all hover:bg-blue-500 disabled:opacity-60">
              <Play className="mr-2 h-6 w-6 transition-transform group-hover:scale-110" />
              {isStarting ? '启动中...' : isCompleted ? '再次启动任务' : '启动扫描、预处理、匹配、去重与入库'}
            </button>
          </div>
        )}

        {errorMessage && (
          <div className="mt-4 rounded-lg border border-rose-500/20 bg-rose-950/40 px-4 py-3 text-sm text-rose-300">
            {errorMessage}
          </div>
        )}
      </div>
    </>
  );
}
  • Step 2: TaskProgressBar.jsx
// 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 (
    <>
      <div className="shrink-0 rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-lg">
        <div className="relative flex items-center justify-between">
          <div className="absolute left-4 right-4 top-1/2 -z-10 h-0.5 -translate-y-1/2 bg-slate-800" />
          <div
            className="absolute left-4 top-1/2 -z-10 h-0.5 -translate-y-1/2 bg-emerald-500 transition-all duration-300"
            style={{ width: `calc(${Math.min(100, (stageIndex / (STAGES.length - 1)) * 100)}% - 2rem)` }}
          />
          {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 (
              <div key={stage.id} className="flex flex-col items-center">
                <div className={`flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-bold transition-colors ${
                  isErrored ? 'border-rose-500 bg-rose-950 text-rose-400'
                  : isActive ? 'border-emerald-400 bg-emerald-500 text-white shadow-[0_0_15px_rgba(16,185,129,0.5)]'
                  : isPast ? 'border-emerald-500 bg-emerald-900 text-emerald-400'
                  : 'border-slate-700 bg-slate-950 text-slate-500'
                }`}>
                  {isPast && !isActive && !isErrored ? <Check className="h-4 w-4" /> : getStageIndex(stage.id) + 1}
                </div>
                <span className={`mt-2 text-xs font-medium ${
                  isErrored ? 'text-rose-400' : isActive ? 'text-emerald-400'
                  : isPast ? 'text-slate-300' : 'text-slate-500'
                }`}>
                  {stage.name}
                </span>
              </div>
            );
          })}
        </div>
      </div>

      <div className="shrink-0 rounded-xl border border-slate-800 bg-slate-900 p-5 shadow-lg">
        <div className="mb-2 flex items-end justify-between">
          <div>
            <div className="mb-1 text-xs font-semibold uppercase tracking-wider text-slate-400">当前状态</div>
            <div className="w-96 truncate font-mono text-sm text-emerald-400">{latestFile}</div>
          </div>
          <div className="text-right">
            <div className="font-mono text-xl font-bold text-white">{progressLabel}</div>
            {task && (
              <div className="text-xs text-slate-500">
                任务 ID: <span className="font-mono">{task.task_id.slice(0, 8)}</span>
              </div>
            )}
          </div>
        </div>
        <div className="h-3 w-full overflow-hidden rounded-full border border-slate-800 bg-slate-950">
          <div className={`h-full ${isRunning ? 'animate-pulse bg-gradient-to-r from-blue-500 to-emerald-400'
            : isCompleted ? 'bg-gradient-to-r from-emerald-500 to-emerald-400'
            : isFailed ? 'bg-gradient-to-r from-rose-500 to-rose-400'
            : 'bg-slate-800'}`}
            style={{ width: progressWidth }} />
        </div>
      </div>
    </>
  );
}

function getStageName(stageId) {
  const stage = STAGES.find((s) => s.id === stageId);
  return stage?.name || stageId;
}
  • Step 3: TaskInfoPanel.jsx
// frontend/src/components/workbench/TaskInfoPanel.jsx

function InfoRow({ label, value }) {
  return (
    <div className="flex items-center justify-between gap-3">
      <span className="text-slate-500">{label}</span>
      <span className="max-w-[180px] truncate font-mono text-slate-300">{value}</span>
    </div>
  );
}

export default function TaskInfoPanel({ task, latestLogId, hasMoreLogs }) {
  return (
    <div className="relative shrink-0 overflow-hidden rounded-xl border border-slate-800 bg-slate-900 p-4">
      <h3 className="mb-3 border-b border-slate-800/50 pb-3 text-[13px] font-bold text-white">当前任务</h3>
      <div className="space-y-3 text-[11px] text-slate-400">
        <InfoRow label="任务状态" value={task?.status || 'idle'} />
        <InfoRow label="当前阶段" value={task?.current_stage || 'scan'} />
        <InfoRow label="开始时间" value={task?.started_at || '-'} />
        <InfoRow label="完成时间" value={task?.completed_at || '-'} />
        <InfoRow label="最近日志 ID" value={latestLogId ?? '-'} />
        <InfoRow label="日志是否截断" value={hasMoreLogs ? '是' : '否'} />
      </div>
    </div>
  );
}
  • Step 4: TaskFileList.jsx
// 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 <span className={`rounded-full border px-1.5 py-0.5 text-[9px] ${cls}`}>{label}</span>;
  }
  const cls = preprocessMap[status] || 'border-slate-700 bg-slate-800 text-slate-300';
  const label = preprocessLabel[status] || status;
  return <span className={`rounded-full border px-1.5 py-0.5 text-[9px] ${cls}`}>{label}</span>;
}

export default function TaskFileList({ items, isLoading }) {
  return (
    <div className="flex min-w-0 flex-1 flex-col rounded-xl border border-slate-800 bg-slate-900 p-4">
      <h3 className="mb-3 flex items-center justify-between border-b border-slate-800 pb-2 text-sm font-semibold text-white">
        <span>任务文件</span>
        <FileSearch className="h-4 w-4 text-slate-500" />
      </h3>
      <div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
        {isLoading ? (
          <div className="mt-10 text-center italic text-slate-600">正在加载任务数据...</div>
        ) : items.length === 0 ? (
          <div className="mt-10 text-center italic text-slate-600">当前暂无任务文件</div>
        ) : (
          items.map((item) => (
            <div key={item.id} className="rounded-lg border border-slate-800 bg-slate-950 px-3 py-2">
              <div className="flex items-start justify-between gap-3">
                <div className="min-w-0">
                  <div className="truncate font-mono text-xs text-slate-200">{item.relative_path}</div>
                  <div className="mt-1 text-[11px] text-slate-500">
                    {item.local_cover ? '已挂载封面' : '无本地封面'} / {item.local_lyric ? '已挂载歌词' : '无本地歌词'}
                  </div>
                  {item.preprocess_message && (
                    <div className="mt-1 line-clamp-2 text-[11px] text-amber-300/80">{item.preprocess_message}</div>
                  )}
                </div>
                <div className="flex shrink-0 flex-col items-end gap-1">
                  <StatusBadge status={item.scan_status} />
                  <StatusBadge status={item.preprocess_status} variant="preprocess" />
                </div>
              </div>
            </div>
          ))
        )}
      </div>
    </div>
  );
}
  • Step 5: TaskLogStream.jsx
// frontend/src/components/workbench/TaskLogStream.jsx
import { ListChecks } from 'lucide-react';
import { formatLogTime } from '../../utils/workbench';

export default function TaskLogStream({ logs, logsEndRef }) {
  return (
    <div className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-800 bg-slate-950 p-0 shadow-inner">
      <div className="absolute left-0 right-0 top-0 z-10 flex h-10 items-center border-b border-slate-800 bg-slate-900/90 px-4 backdrop-blur-sm">
        <ListChecks className="mr-2 h-4 w-4 text-slate-400" />
        <span className="text-sm font-semibold text-white">任务记录流</span>
      </div>
      <div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto px-4 pb-4 pt-12 font-mono text-[11px]">
        {logs.length === 0 ? (
          <div className="mt-10 text-center italic text-slate-600">等待任务启动...</div>
        ) : (
          logs.map((log) => (
            <div key={log.id} className="flex gap-2 leading-tight">
              <span className="shrink-0 text-slate-500">[{formatLogTime(log.created_at)}]</span>
              <span className={`break-all ${
                log.level === 'error' ? 'text-rose-400'
                : log.level === 'success' ? 'text-emerald-400/80'
                : log.level === 'warning' ? 'text-amber-300'
                : 'text-blue-300'
              }`}>
                {log.message}
              </span>
            </div>
          ))
        )}
        <div ref={logsEndRef} />
      </div>
    </div>
  );
}
  • Step 6: Commit
git add frontend/src/components/workbench/
git commit -m "feat: add TaskControlPanel, TaskProgressBar, TaskInfoPanel, TaskFileList, TaskLogStream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"

Phase 5: 重写 WorkbenchPage 容器

Task 5.1: 重写 WorkbenchPage.jsx

Files:

  • Modify: frontend/src/pages/WorkbenchPage.jsx

  • Step 1: 用新的 hooks 和组件重写 WorkbenchPage.jsx

// 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 (
    <div className="flex h-full min-h-0 gap-6 overflow-hidden">
      {/* Left Column */}
      <div className="flex w-1/3 min-h-0 flex-col gap-6">
        <TaskControlPanel
          config={config}
          canStart={canStart}
          isRunning={isRunning}
          isCompleted={isCompleted}
          isFailed={isFailed}
          onStart={handleStart}
          isStarting={isStarting}
          errorMessage={errorMessage}
          activeStageName={task?.current_stage || 'scan'}
        />
      </div>

      {/* Right Column */}
      <div className="flex w-2/3 min-h-0 flex-col gap-4">
        <TaskProgressBar
          task={task}
          isRunning={isRunning}
          isCompleted={isCompleted}
          isFailed={isFailed}
          latestFile={latestFile}
        />

        <div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
          {/* Stats + Info Sidebar */}
          <div className="flex h-full w-72 min-h-0 shrink-0 flex-col gap-4">
            <div className="flex min-h-0 flex-1 flex-col rounded-xl border border-slate-800 bg-slate-900 p-4">
              <h3 className="mb-3 shrink-0 border-b border-slate-800 pb-3 text-[13px] font-bold text-white">
                阶段统计
              </h3>
              <div className="min-h-0 flex-1 space-y-5 overflow-y-auto pr-1 pb-2">
                <StageStatsPanel stageName="scan" stats={scanStats} />
                <StageStatsPanel stageName="preprocess" stats={preprocessStats} />
                <StageStatsPanel stageName="match" stats={matchStats} />
                <StageStatsPanel stageName="dedupe" stats={dedupeStats} />
                <StageStatsPanel stageName="organize" stats={organizeStats} />
              </div>
            </div>

            <TaskInfoPanel
              task={task}
              latestLogId={latestLogId}
              hasMoreLogs={hasMoreLogs}
            />
          </div>

          {/* File List + Log Stream */}
          <TaskFileList items={items} isLoading={isLoading} />
          <TaskLogStream logs={logs} logsEndRef={logsEndRef} />
        </div>
      </div>
    </div>
  );
}
  • Step 2: 验证构建
cd frontend && npm run build

Expected: PASS

  • Step 3: Commit
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 <noreply@anthropic.com>"

验证清单

  • npm run build 成功,无错误
  • 任务启动按钮正常
  • 5 阶段进度条正常显示
  • 阶段统计面板正常(5 个阶段所有统计卡片)
  • 任务文件列表正常
  • 实时日志流正常
  • WebSocket 事件处理正常(启动/进度/完成/失败/日志追加)
  • 配置目录展示正常
  • 任务失败/重试流程正常