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:
liumangmang
2026-05-07 23:22:17 +08:00
parent b352d43d64
commit 75dfdeb756
9 changed files with 731 additions and 0 deletions
@@ -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;
}
+31
View File
@@ -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 };
}
+177
View File
@@ -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
};
}
+130
View File
@@ -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());
}