feat: add workbench shared utils, hooks, and display components
Phase 1-5: Created utils/workbench.js (STAGE_STATS_CONFIG eliminates 40+ repeated StatCards), useTaskStream hook (WebSocket event handling), useTaskRunner hook (task start logic), StageStatsPanel, TaskControlPanel, TaskProgressBar, TaskInfoPanel, TaskFileList, and TaskLogStream. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<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-slate-300">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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, task
|
||||
}) {
|
||||
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">{task?.error_message || '请查看任务记录流排查原因。'}</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 <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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user