diff --git a/frontend/src/components/workbench/StageStatsPanel.jsx b/frontend/src/components/workbench/StageStatsPanel.jsx new file mode 100644 index 0000000..639c016 --- /dev/null +++ b/frontend/src/components/workbench/StageStatsPanel.jsx @@ -0,0 +1,62 @@ +// frontend/src/components/workbench/StageStatsPanel.jsx +import { STAGE_STATS_CONFIG, STAGE_EXTRA_FIELDS } from '../../utils/workbench'; + +export default function StageStatsPanel({ stageName, stats }) { + const config = STAGE_STATS_CONFIG[stageName]; + const extraField = STAGE_EXTRA_FIELDS[stageName]; + if (!config || !stats) return null; + + const stageLabels = { + scan: 'Scan', preprocess: 'Preprocess', + match: 'Match', dedupe: 'Dedupe', organize: 'Organize' + }; + + return ( +
+
+ {stageLabels[stageName] || stageName} +
+
+ {config.map(({ key, label, color, error }) => ( + + ))} +
+ {extraField && ( +
+
{extraField.label}
+
+ {stats[extraField.key] ?? 0} +
+
+ )} +
+ ); +} + +function StatCard({ label, value, color, error }) { + const colorMap = { + white: 'text-white', emerald: 'text-emerald-400', + amber: 'text-amber-400', rose: 'text-rose-400', + blue: 'text-blue-300', cyan: 'text-cyan-300', + indigo: 'text-indigo-300', sky: 'text-sky-300', + slate: 'text-slate-300', orange: 'text-orange-300' + }; + const labelMap = { + emerald: 'text-emerald-500', amber: 'text-amber-500', + rose: 'text-rose-500', blue: 'text-blue-400', + cyan: 'text-cyan-400', indigo: 'text-indigo-400', sky: 'text-sky-400' + }; + + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/src/components/workbench/TaskControlPanel.jsx b/frontend/src/components/workbench/TaskControlPanel.jsx new file mode 100644 index 0000000..ab10303 --- /dev/null +++ b/frontend/src/components/workbench/TaskControlPanel.jsx @@ -0,0 +1,111 @@ +// frontend/src/components/workbench/TaskControlPanel.jsx +import { useNavigate } from 'react-router-dom'; +import { + Activity, AlertTriangle, CheckCircle2, Edit3, + Folder, Play, Settings +} from 'lucide-react'; + +function DirectoryField({ label, value, missingText }) { + return ( +
+ +
+ {value || missingText} +
+
+ ); +} + +export default function TaskControlPanel({ + config, canStart, isRunning, isCompleted, isFailed, + onStart, isStarting, errorMessage, activeStageName, task +}) { + const navigate = useNavigate(); + + return ( + <> +
+
+

+ + 处理编排 +

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

后台任务执行中

+

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

+
+ ) : isFailed ? ( +
+
+ +
+

后台任务失败

+

{task?.error_message || '请查看任务记录流排查原因。'}

+ +
+ ) : ( +
+ {isCompleted && ( +
+ +
+ )} +

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

+ +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ + ); +} diff --git a/frontend/src/components/workbench/TaskFileList.jsx b/frontend/src/components/workbench/TaskFileList.jsx new file mode 100644 index 0000000..8cf72e4 --- /dev/null +++ b/frontend/src/components/workbench/TaskFileList.jsx @@ -0,0 +1,71 @@ +// frontend/src/components/workbench/TaskFileList.jsx +import { FileSearch } from 'lucide-react'; + +function StatusBadge({ status, variant = 'scan' }) { + const scanMap = { + queued: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400', + skipped_locked: 'border-amber-500/20 bg-amber-500/10 text-amber-400', + invalid: 'border-rose-500/20 bg-rose-500/10 text-rose-400' + }; + const scanLabel = { queued: '已入队', skipped_locked: '已跳过', invalid: '无效' }; + const preprocessMap = { + pending: 'border-slate-700 bg-slate-800 text-slate-300', + skipped: 'border-slate-700 bg-slate-800 text-slate-400', + running: 'border-blue-500/20 bg-blue-500/10 text-blue-300', + completed: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-400', + warning: 'border-amber-500/20 bg-amber-500/10 text-amber-400', + failed: 'border-rose-500/20 bg-rose-500/10 text-rose-400', + replaced_by_split: 'border-cyan-500/20 bg-cyan-500/10 text-cyan-300' + }; + const preprocessLabel = { + pending: '待预处理', skipped: '不处理', running: '处理中', + completed: '已完成', warning: '有警告', failed: '失败', replaced_by_split: '已切轨' + }; + + if (variant === 'scan') { + const cls = scanMap[status] || 'border-slate-700 bg-slate-800 text-slate-300'; + const label = scanLabel[status] || status; + return {label}; + } + const cls = preprocessMap[status] || 'border-slate-700 bg-slate-800 text-slate-300'; + const label = preprocessLabel[status] || status; + return {label}; +} + +export default function TaskFileList({ items, isLoading }) { + return ( +
+

+ 任务文件 + +

+
+ {isLoading ? ( +
正在加载任务数据...
+ ) : items.length === 0 ? ( +
当前暂无任务文件
+ ) : ( + items.map((item) => ( +
+
+
+
{item.relative_path}
+
+ {item.local_cover ? '已挂载封面' : '无本地封面'} / {item.local_lyric ? '已挂载歌词' : '无本地歌词'} +
+ {item.preprocess_message && ( +
{item.preprocess_message}
+ )} +
+
+ + +
+
+
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/components/workbench/TaskInfoPanel.jsx b/frontend/src/components/workbench/TaskInfoPanel.jsx new file mode 100644 index 0000000..5d3693b --- /dev/null +++ b/frontend/src/components/workbench/TaskInfoPanel.jsx @@ -0,0 +1,26 @@ +// frontend/src/components/workbench/TaskInfoPanel.jsx + +function InfoRow({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +export default function TaskInfoPanel({ task, latestLogId, hasMoreLogs }) { + return ( +
+

当前任务

+
+ + + + + + +
+
+ ); +} diff --git a/frontend/src/components/workbench/TaskLogStream.jsx b/frontend/src/components/workbench/TaskLogStream.jsx new file mode 100644 index 0000000..fe09546 --- /dev/null +++ b/frontend/src/components/workbench/TaskLogStream.jsx @@ -0,0 +1,34 @@ +// frontend/src/components/workbench/TaskLogStream.jsx +import { ListChecks } from 'lucide-react'; +import { formatLogTime } from '../../utils/workbench'; + +export default function TaskLogStream({ logs, logsEndRef }) { + return ( +
+
+ + 任务记录流 +
+
+ {logs.length === 0 ? ( +
等待任务启动...
+ ) : ( + logs.map((log) => ( +
+ [{formatLogTime(log.created_at)}] + + {log.message} + +
+ )) + )} +
+
+
+ ); +} diff --git a/frontend/src/components/workbench/TaskProgressBar.jsx b/frontend/src/components/workbench/TaskProgressBar.jsx new file mode 100644 index 0000000..43c337d --- /dev/null +++ b/frontend/src/components/workbench/TaskProgressBar.jsx @@ -0,0 +1,89 @@ +// frontend/src/components/workbench/TaskProgressBar.jsx +import { Check } from 'lucide-react'; +import { STAGES, getStageIndex } from '../../constants'; + +export default function TaskProgressBar({ task, isRunning, isCompleted, isFailed, latestFile }) { + const stageIndex = getStageIndex(task?.current_stage || 'scan'); + const DEFAULT_STAGE_STATES = { + scan: 'pending', preprocess: 'pending', match: 'pending', + dedupe: 'pending', organize: 'pending', complete: 'pending' + }; + + const activeStageName = getStageName(task?.current_stage || 'scan'); + const progressLabel = isRunning + ? `${activeStageName}进行中` + : isCompleted ? '五阶段处理已完成' + : isFailed ? `${activeStageName}执行失败` + : '等待启动后台任务'; + const progressWidth = task + ? `${Math.max(8, ((stageIndex + (isRunning ? 0.5 : 1)) / STAGES.length) * 100)}%` + : '0%'; + + return ( + <> +
+
+
+
+ {STAGES.map((stage) => { + const stageState = task?.stage_states?.[stage.id] || DEFAULT_STAGE_STATES[stage.id]; + const isActive = stageState === 'running'; + const isPast = stageState === 'completed' || stageState === 'skipped'; + const isErrored = stageState === 'failed'; + + return ( +
+
+ {isPast && !isActive && !isErrored ? : getStageIndex(stage.id) + 1} +
+ + {stage.name} + +
+ ); + })} +
+
+ +
+
+
+
当前状态
+
{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; +} diff --git a/frontend/src/hooks/useTaskRunner.js b/frontend/src/hooks/useTaskRunner.js new file mode 100644 index 0000000..5ff8533 --- /dev/null +++ b/frontend/src/hooks/useTaskRunner.js @@ -0,0 +1,31 @@ +// frontend/src/hooks/useTaskRunner.js +import { useState, useCallback } from 'react'; +import { runTask } from '../api/tasks'; + +export default function useTaskRunner({ hydrateTask }) { + const [isStarting, setIsStarting] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + const handleStart = useCallback(async (canStart) => { + if (!canStart) { + window.alert('请先在设置中配置目录!'); + return; + } + setIsStarting(true); + setErrorMessage(''); + try { + const response = await runTask(); + await hydrateTask(response.task_id, true); + } catch (error) { + if (error.status === 409 && error.taskId) { + await hydrateTask(error.taskId, true); + } else { + setErrorMessage(error.message || '启动失败'); + } + } finally { + setIsStarting(false); + } + }, [hydrateTask]); + + return { isStarting, errorMessage, setErrorMessage, handleStart }; +} diff --git a/frontend/src/hooks/useTaskStream.js b/frontend/src/hooks/useTaskStream.js new file mode 100644 index 0000000..48c40d1 --- /dev/null +++ b/frontend/src/hooks/useTaskStream.js @@ -0,0 +1,177 @@ +// frontend/src/hooks/useTaskStream.js +import { useState, useEffect, useRef, useCallback } from 'react'; +import { createTaskStream, fetchCurrentTask, fetchTask, fetchTaskItems } from '../api/tasks'; +import { + DEFAULT_STAGE_STATES, + extractLatestItemPath, extractItemsFromEvent, mergeById +} from '../utils/workbench'; + +export default function useTaskStream() { + const [task, setTask] = useState(null); + const [items, setItems] = useState([]); + const [logs, setLogs] = useState([]); + const [latestFile, setLatestFile] = useState('-'); + const [hasMoreLogs, setHasMoreLogs] = useState(false); + const [latestLogId, setLatestLogId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [connState, setConnState] = useState('polling'); + + const streamRef = useRef(null); + + const closeStream = useCallback(() => { + if (streamRef.current) { + streamRef.current.close(); + streamRef.current = null; + } + }, []); + + const openStream = useCallback((taskId) => { + closeStream(); + const stream = createTaskStream(taskId); + streamRef.current = stream; + + stream.onopen = () => setConnState('connected'); + + stream.onmessage = (event) => { + const payload = JSON.parse(event.data); + handleStreamEvent(payload); + }; + + stream.onerror = () => setConnState('polling'); + stream.onclose = () => setConnState('polling'); + }, [closeStream]); + + async function handleStreamEvent(event) { + if (event.type === 'task.snapshot') { + const snapshotTask = event.data.task; + setTask(snapshotTask); + setLogs(event.data.recent_logs || []); + setHasMoreLogs(event.data.has_more_logs || false); + setLatestLogId(event.data.latest_log_id || null); + const snapshotLatestPath = extractLatestItemPath(event.data.recent_logs || []); + if (snapshotLatestPath) setLatestFile(snapshotLatestPath); + return; + } + + const eventItems = extractItemsFromEvent(event); + if (eventItems.length > 0) { + setItems((currentItems) => mergeById(currentItems, eventItems)); + setLatestFile((currentLatest) => { + const newPath = eventItems.at(-1)?.current_file_path || eventItems.at(-1)?.relative_path; + return newPath || currentLatest; + }); + } + + if (event.type === 'log.appended' && event.data?.log) { + setLogs((currentLogs) => mergeById(currentLogs, [event.data.log])); + setLatestLogId(event.data.log.id); + const eventItemPath = extractLatestItemPath([event.data.log]); + if (eventItemPath) setLatestFile(eventItemPath); + } + + if (event.type === 'task.started') { + setTask((currentTask) => + currentTask ? { + ...currentTask, + status: event.data.status, + current_stage: event.data.current_stage || 'scan', + stage_states: { ...(currentTask.stage_states || DEFAULT_STAGE_STATES), scan: 'running' } + } : currentTask + ); + return; + } + + if (event.type === 'stage.started') { + setTask((currentTask) => + currentTask ? { + ...currentTask, + current_stage: event.stage, + stage_states: { ...(currentTask.stage_states || DEFAULT_STAGE_STATES), [event.stage]: 'running' } + } : currentTask + ); + return; + } + + if ( + event.type === 'scan.progress' || event.type === 'preprocess.progress' + || event.type === 'match.progress' || event.type === 'dedupe.progress' + || event.type === 'organize.progress' + ) { + setTask((currentTask) => + currentTask ? { + ...currentTask, + current_stage: event.stage, + status: 'running', + stats: event.data.stats + } : currentTask + ); + return; + } + + if (event.type === 'stage.completed') { + setTask((currentTask) => + currentTask ? { + ...currentTask, + stage_states: { ...(currentTask.stage_states || DEFAULT_STAGE_STATES), [event.stage]: 'completed' }, + stats: event.data.stats || currentTask.stats + } : currentTask + ); + return; + } + + if (event.type === 'task.completed' || event.type === 'task.failed') { + try { + const response = await fetchTask(event.task_id); + setTask(response.task); + } catch (err) { + console.error('Failed to refresh task summary', err); + } + } + } + + async function hydrateTask(taskId, connectStream = true) { + const [taskResponse, itemResponse] = await Promise.all([ + fetchTask(taskId), + fetchTaskItems(taskId, { pageSize: 200 }) + ]); + setTask(taskResponse.task); + setItems(itemResponse.items); + const lastItem = itemResponse.items.at(-1); + setLatestFile(lastItem?.current_file_path || lastItem?.relative_path || lastItem?.original_path || '-'); + if (connectStream) openStream(taskId); + } + + const loadCurrentTask = useCallback(async () => { + setIsLoading(true); + try { + const response = await fetchCurrentTask(); + if (!response.task) { + setTask(null); setItems([]); setLogs([]); + setLatestFile('-'); setHasMoreLogs(false); + setLatestLogId(null); closeStream(); + setConnState('polling'); + return; + } + await hydrateTask(response.task.task_id, true); + } catch (error) { + console.error('Failed to load current task', error); + closeStream(); + setConnState('polling'); + } finally { + setIsLoading(false); + } + }, [closeStream, openStream]); + + useEffect(() => { + loadCurrentTask(); + return () => { closeStream(); setConnState('polling'); }; + }, []); + + return { + task, items, logs, latestFile, hasMoreLogs, latestLogId, + isLoading, connState, + setTask, setItems, setLogs, + loadCurrentTask, hydrateTask, + setConnState + }; +} diff --git a/frontend/src/utils/workbench.js b/frontend/src/utils/workbench.js new file mode 100644 index 0000000..4d8263b --- /dev/null +++ b/frontend/src/utils/workbench.js @@ -0,0 +1,130 @@ +// frontend/src/utils/workbench.js + +export const DEFAULT_STAGE_STATES = { + scan: 'pending', + preprocess: 'pending', + match: 'pending', + dedupe: 'pending', + organize: 'pending', + complete: 'pending' +}; + +export const EMPTY_SCAN_STATS = { + total_found: 0, queued: 0, skipped_locked: 0, + skipped_invalid: 0, ignored_non_audio: 0 +}; + +export const EMPTY_PREPROCESS_STATS = { + input_items: 0, output_items: 0, split_parents: 0, + generated_children: 0, converted_items: 0, metadata_snapshots: 0, + fingerprints_ok: 0, fingerprints_failed: 0, + failed_items: 0, warning_items: 0 +}; + +export const EMPTY_MATCH_STATS = { + input_items: 0, matched_authoritative: 0, matched_fallback: 0, + low_score: 0, not_found: 0, provider_warnings: 0, failed_items: 0 +}; + +export const EMPTY_DEDUPE_STATS = { + input_items: 0, library_candidates: 0, batch_duplicates: 0, + library_duplicates: 0, replaced_library_items: 0, + kept_items: 0, failed_items: 0 +}; + +export const EMPTY_ORGANIZE_STATS = { + input_items: 0, moved_items: 0, renamed_items: 0, + collision_resolved: 0, trashed_items: 0, failed_items: 0 +}; + +export const STAGE_STATS_CONFIG = { + scan: [ + { key: 'total_found', label: '候选音频', color: 'white' }, + { key: 'queued', label: '成功入队', color: 'emerald' }, + { key: 'skipped_locked', label: '最近写入跳过', color: 'amber' }, + { key: 'skipped_invalid', label: '无效文件', color: 'rose', error: true } + ], + preprocess: [ + { key: 'input_items', label: '输入项目', color: 'white' }, + { key: 'output_items', label: '有效输出', color: 'emerald' }, + { key: 'split_parents', label: '切轨父项', color: 'blue' }, + { key: 'generated_children', label: '生成子轨', color: 'cyan' }, + { key: 'converted_items', label: '已转码', color: 'indigo' }, + { key: 'metadata_snapshots', label: '元数据快照', color: 'slate' }, + { key: 'fingerprints_ok', label: '指纹成功', color: 'emerald' }, + { key: 'fingerprints_failed', label: '指纹警告', color: 'amber' }, + { key: 'warning_items', label: '项目警告', color: 'amber' }, + { key: 'failed_items', label: '项目失败', color: 'rose', error: true } + ], + match: [ + { key: 'input_items', label: '进入匹配', color: 'white' }, + { key: 'matched_authoritative', label: '权威命中', color: 'emerald' }, + { key: 'matched_fallback', label: 'Fallback 命中', color: 'sky' }, + { key: 'low_score', label: '低分待审', color: 'amber' }, + { key: 'not_found', label: '未命中', color: 'slate' }, + { key: 'provider_warnings', label: 'Provider 告警', color: 'orange' }, + { key: 'failed_items', label: '请求失败', color: 'rose', error: true } + ], + dedupe: [ + { key: 'input_items', label: '进入去重', color: 'white' }, + { key: 'library_candidates', label: '库内候选', color: 'slate' }, + { key: 'batch_duplicates', label: '批次重复', color: 'amber' }, + { key: 'library_duplicates', label: '库内重复', color: 'amber' }, + { key: 'replaced_library_items', label: '替换旧库', color: 'cyan' }, + { key: 'kept_items', label: '保留项目', color: 'emerald' }, + { key: 'failed_items', label: '处理失败', color: 'rose', error: true } + ], + organize: [ + { key: 'input_items', label: '进入入库', color: 'white' }, + { key: 'moved_items', label: '移动完成', color: 'emerald' }, + { key: 'renamed_items', label: '重命名', color: 'cyan' }, + { key: 'collision_resolved', label: '冲突化解', color: 'sky' }, + { key: 'trashed_items', label: '失败入桶', color: 'amber' }, + { key: 'failed_items', label: '处理失败', color: 'rose', error: true } + ] +}; + +export const STAGE_EXTRA_FIELDS = { + scan: { key: 'ignored_non_audio', label: '静默忽略非音频', color: 'slate' } +}; + +// --- Utility functions --- + +export function formatLogTime(value) { + if (!value) return '--:--:--'; + try { + return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }); + } catch { + return String(value); + } +} + +export function resolveItemDisplayPath(item) { + if (!item) return null; + return item.current_file_path || item.relative_path || item.original_path; +} + +export function extractLatestItemPath(logs) { + for (let i = logs.length - 1; i >= 0; i--) { + const path = resolveItemDisplayPath(logs[i]); + if (path) return path; + } + return null; +} + +export function extractItemsFromEvent(event) { + if (event.type === 'scan.progress' || event.type === 'preprocess.progress' + || event.type === 'match.progress' || event.type === 'dedupe.progress' + || event.type === 'organize.progress') { + return event.data?.updated_items || event.data?.items || []; + } + if (event.data?.items) return event.data.items; + if (event.data?.item) return [event.data.item]; + return []; +} + +export function mergeById(existing, incoming) { + const map = new Map(existing.map((item) => [item.id, item])); + incoming.forEach((item) => { map.set(item.id, { ...map.get(item.id), ...item }); }); + return Array.from(map.values()); +}