Files
Pre_Seg_Server/src/components/Dashboard.tsx
admin 29a1a87e52 feat: 完善 SAM2.1 模型选择与标注工作流
- 后端 SAM2 引擎新增 sam2.1_hiera_tiny、sam2.1_hiera_small、sam2.1_hiera_base_plus、sam2.1_hiera_large 四个变体定义,并按变体维护 checkpoint/config、image predictor、video predictor、加载状态、错误信息和真实状态回报。

- 后端 SAM registry 仅暴露当前产品启用的 SAM2.1 变体,保留 sam2 作为 tiny 兼容别名,拒绝 sam3 产品入口,并把 point、box、interactive、auto、propagate 都分发到所选 SAM2.1 变体。

- 后端默认配置和下载脚本切换到 SAM2.1 checkpoint 命名,支持 legacy SAM2 checkpoint fallback,并在状态消息中标出 fallback 使用情况。

- 前端全局 AI 模型状态新增 SAM2.1 tiny/small/base+/large 类型和默认 tiny,API 请求默认携带 sam2.1_hiera_tiny,AI 页面提供模型变体选择和所选模型状态展示。

- AI 智能分割页移除当前产品不使用的 SAM3/文本提示入口,保留正向点、反向点、框选和参数开关;AI 页只展示本页生成的候选 mask,并支持遮罩清晰度调节、候选 mask 上继续加正/反点、清空本页候选、推送到工作区编辑。

- 工作区和 Canvas 补强 SAM2 交互式细化链路:框选后正/反点继续细化同一个候选 mask,反向点请求启用背景过滤,空结果会移除被否定候选;AI 推送到工作区后保留选中态和未保存 draft mask。

- 工作区标注保存闭环补强:未保存 mask 可归档保存,dirty saved mask 可更新,保存后用后端 saved annotation 替换已提交 draft,清空/删除已保存 mask 时同步后端删除。

- Dashboard 任务进度区改为展示 queued、running、success、failed、cancelled 最近任务,处理中统计只计算 queued/running,并保留近期完成记录。

- 时间轴在顶部时间进度条和底部缩略图导航轴之间新增已编辑帧标记带,基于当前项目帧内 masks 标出已有编辑/标注的帧,并支持点击标记跳转。

- 前端测试覆盖 SAM2.1 变体选择、模型状态徽标、AI 页候选隔离、遮罩透明度、候选上追加正/反点、推送工作区保留选择、Canvas 交互式细化、VideoWorkspace 传播/保存、Dashboard 进度和时间轴已编辑帧标记。

- 后端测试覆盖 SAM2.1 变体状态、sam2 alias 兼容、sam3 禁用、semantic 禁用、传播标注保存、Dashboard 最近任务状态和 SAM3 历史测试跳过说明。

- README、AGENTS 和 doc 文档同步当前真实进度,更新 SAM2.1 变体、SAM3 禁用、接口契约、设计冻结、需求冻结、前端元素审计、实施计划、FastAPI docs 说明和测试矩阵。
2026-05-01 23:39:53 +08:00

444 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { Activity, AlertTriangle, Clock, Folders, CheckCircle2, Info, Loader2, RotateCcw, XCircle } from 'lucide-react';
import { progressWS, type ConnectionStatus, type ProgressMessage } from '../lib/websocket';
import { cn } from '../lib/utils';
import {
cancelTask,
getDashboardOverview,
getTask,
retryTask,
type DashboardActivity,
type DashboardOverview,
type DashboardTask,
type ProcessingTask,
} from '../lib/api';
const emptySummary: DashboardOverview['summary'] = {
project_count: 0,
parsing_task_count: 0,
annotation_count: 0,
frame_count: 0,
template_count: 0,
system_load_percent: 0,
};
export function Dashboard() {
const [summary, setSummary] = useState<DashboardOverview['summary']>(emptySummary);
const [tasks, setTasks] = useState<DashboardTask[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [activityLog, setActivityLog] = useState<DashboardActivity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [selectedTask, setSelectedTask] = useState<ProcessingTask | null>(null);
const [taskActionMessage, setTaskActionMessage] = useState('');
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
const taskFromProcessingTask = (task: ProcessingTask, name = `任务 ${task.id}`): DashboardTask => ({
id: `task-${task.id}`,
task_id: task.id,
project_id: task.project_id ?? 0,
name,
progress: task.progress,
status: task.message || task.status,
raw_status: task.status,
error: task.error,
frame_count: Number(task.result?.frames_extracted || 0),
updated_at: task.updated_at,
});
const prependActivity = (message: string, project = '系统') => {
setActivityLog((prev) => [
{ id: `task-action-${Date.now()}`, kind: 'task', time: new Date().toISOString(), message, project },
...prev.slice(0, 9),
]);
};
useEffect(() => {
let cancelled = false;
const loadOverview = () => {
getDashboardOverview()
.then((overview) => {
if (cancelled) return;
setSummary(overview.summary);
setTasks((prev) => {
if (prev.length === 0) return overview.tasks;
const overviewIds = new Set(overview.tasks.map((task) => task.id));
const wsOnly = prev.filter((task) => !task.id.startsWith('task-') && !overviewIds.has(task.id) && task.progress < 100);
return [...overview.tasks, ...wsOnly];
});
setActivityLog((prev) => {
if (prev.length === 0) return overview.activity;
const byId = new Map(prev.map((item) => [item.id, item]));
overview.activity.forEach((item) => byId.set(item.id, item));
return Array.from(byId.values()).slice(0, 10);
});
setLoadError('');
})
.catch((err) => {
console.error('Failed to load dashboard overview:', err);
if (!cancelled) setLoadError('Dashboard 数据加载失败');
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
};
loadOverview();
const overviewInterval = setInterval(loadOverview, 5000);
return () => {
cancelled = true;
clearInterval(overviewInterval);
};
}, []);
useEffect(() => {
let mounted = true;
const taskTitle = (data: ProgressMessage) => data.filename || data.projectName || data.taskId || '后台任务';
const timer = setTimeout(() => {
if (mounted) progressWS.connect();
}, 500);
const unsubscribe = progressWS.onProgress((data: ProgressMessage) => {
if (!mounted) return;
setIsConnected(progressWS.isConnected());
if (data.type === 'progress' && data.taskId) {
setTasks((prev) => {
const exists = prev.find((t) => t.id === data.taskId);
if (exists) {
return prev.map((t) =>
t.id === data.taskId
? { ...t, progress: data.progress ?? t.progress, status: data.status ?? t.status }
: t
);
}
return [
...prev,
{
id: data.taskId!,
project_id: data.project_id ?? Number(data.task_id || 0),
name: taskTitle(data),
progress: data.progress ?? 0,
status: data.status ?? '处理中',
raw_status: 'running',
error: data.error,
frame_count: 0,
updated_at: new Date().toISOString(),
},
];
});
}
if (data.type === 'complete' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId ? { ...t, progress: 100, status: '已完成', raw_status: 'success' } : t
)
);
setActivityLog((prev) => [
{ id: `ws-complete-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `解析完成: ${taskTitle(data)}`, project: data.projectName || '系统' },
...prev.slice(0, 9),
]);
}
if (data.type === 'cancelled' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId
? { ...t, progress: 100, status: data.message || '任务已取消', raw_status: 'cancelled', error: data.error }
: t
)
);
setActivityLog((prev) => [
{ id: `ws-cancelled-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `任务已取消: ${taskTitle(data)}`, project: data.projectName || '系统' },
...prev.slice(0, 9),
]);
}
if (data.type === 'error' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId
? { ...t, progress: data.progress ?? t.progress, status: `错误: ${data.error || data.message || '未知错误'}`, raw_status: 'failed', error: data.error }
: t
)
);
setActivityLog((prev) => [
{ id: `ws-error-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `解析失败: ${taskTitle(data)}`, project: data.projectName || '系统' },
...prev.slice(0, 9),
]);
}
if (data.type === 'status') {
setActivityLog((prev) => [
{ id: `ws-status-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || '状态更新', project: '系统' },
...prev.slice(0, 9),
]);
}
});
const unsubscribeStatus = progressWS.onStatus((status: ConnectionStatus) => {
if (mounted) setIsConnected(status === 'connected');
});
const checkConnection = setInterval(() => {
if (mounted) setIsConnected(progressWS.isConnected());
}, 5000);
return () => {
mounted = false;
unsubscribe();
unsubscribeStatus();
clearInterval(checkConnection);
progressWS.disconnect();
};
}, []);
const stats = [
{ label: '项目总数', value: summary.project_count.toString(), icon: Folders, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '处理任务', value: summary.parsing_task_count.toString(), icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
{ label: '已存标注', value: summary.annotation_count.toString(), icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-400/10' },
{ label: '系统负载', value: `${summary.system_load_percent}%`, icon: Activity, color: 'text-cyan-400', bg: 'bg-cyan-400/10' },
];
function formatActivityTime(value: string | null): string {
if (!value) return '未知时间';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const taskRawStatus = (task: DashboardTask): string => task.raw_status || (
task.status.includes('取消') ? 'cancelled'
: task.status.includes('失败') || task.status.includes('错误') ? 'failed'
: task.progress >= 100 ? 'success'
: 'running'
);
const canCancel = (task: DashboardTask): boolean => ['queued', 'running'].includes(taskRawStatus(task)) && Boolean(task.task_id);
const canRetry = (task: DashboardTask): boolean => ['failed', 'cancelled'].includes(taskRawStatus(task)) && Boolean(task.task_id);
const handleCancelTask = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
const updated = await cancelTask(task.task_id);
setTasks((prev) => prev.map((item) => (
item.id === task.id ? taskFromProcessingTask(updated, task.name) : item
)));
prependActivity(`任务已取消 #${updated.id}`, task.name);
} catch (err) {
console.error('Cancel task failed:', err);
setTaskActionMessage('任务取消失败,请检查后端服务');
} finally {
setBusyTaskId(null);
}
};
const handleRetryTask = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
const retried = await retryTask(task.task_id);
const dashboardTask = taskFromProcessingTask(retried, task.name);
setTasks((prev) => [dashboardTask, ...prev.filter((item) => item.id !== dashboardTask.id)]);
prependActivity(`重试任务已入队 #${retried.id}`, task.name);
} catch (err) {
console.error('Retry task failed:', err);
setTaskActionMessage('任务重试失败,请检查后端服务');
} finally {
setBusyTaskId(null);
}
};
const handleOpenTaskDetail = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
setSelectedTask(await getTask(task.task_id));
} catch (err) {
console.error('Load task detail failed:', err);
setTaskActionMessage('失败详情加载失败');
} finally {
setBusyTaskId(null);
}
};
return (
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
<header className="mb-8">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-medium tracking-tight text-white"></h1>
<div className={cn(
"flex items-center gap-1.5 text-[10px] uppercase font-mono px-2 py-1 rounded border",
isConnected
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-amber-500/10 text-amber-400 border-amber-500/20"
)}>
<div className={cn("w-1.5 h-1.5 rounded-full", isConnected ? "bg-emerald-500" : "bg-amber-500 animate-pulse")} />
{isConnected ? 'WebSocket 已连接' : 'WebSocket 断开'}
</div>
</div>
<p className="text-gray-400 text-sm mt-1"></p>
{loadError && <p className="text-red-400 text-xs mt-2">{loadError}</p>}
{taskActionMessage && <p className="text-amber-400 text-xs mt-2">{taskActionMessage}</p>}
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{stats.map((stat, i) => {
const Icon = stat.icon;
return (
<div key={i} className="bg-[#111] border border-white/5 p-5 rounded-xl block transition-all hover:border-white/20">
<div className="flex items-center gap-4 mb-4">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${stat.bg} ${stat.color}`}>
<Icon size={20} />
</div>
<div className="text-xl font-mono text-gray-100">{stat.value}</div>
</div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-widest">{stat.label}</div>
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-[#111] border border-white/5 rounded-xl p-6 min-h-[400px]">
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6"> ( / )</h2>
<div className="space-y-4">
{isLoading && (
<div className="text-sm text-gray-500 text-center py-12"> Dashboard ...</div>
)}
{tasks.map((task) => (
<div key={task.id} className="bg-[#0d0d0d] border border-white/5 p-4 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-mono text-sm text-gray-200">{task.name}</span>
<span className="text-xs text-cyan-400 font-mono">{task.progress}%</span>
</div>
<div className="w-full h-1.5 bg-white/5 rounded-full overflow-hidden mb-2">
<div className="h-full bg-gradient-to-r from-cyan-600 to-cyan-400 rounded-full transition-all duration-500" style={{ width: `${task.progress}%` }} />
</div>
<div className="text-xs text-gray-500 flex items-center gap-2">
{taskRawStatus(task) === 'success' || task.status === '已完成' ? (
<CheckCircle2 size={12} className="text-emerald-400" />
) : taskRawStatus(task) === 'failed' ? (
<AlertTriangle size={12} className="text-red-400" />
) : taskRawStatus(task) === 'cancelled' ? (
<XCircle size={12} className="text-amber-400" />
) : (
<Loader2 size={12} className="text-cyan-400 animate-spin" />
)}
{task.status}
<span className="text-gray-600">: {task.frame_count}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{canCancel(task) && (
<button
onClick={() => handleCancelTask(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-red-500/20 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<XCircle size={12} />
</button>
)}
{canRetry(task) && (
<button
onClick={() => handleRetryTask(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[11px] text-cyan-300 hover:bg-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<RotateCcw size={12} />
</button>
)}
{task.task_id && (
<button
onClick={() => handleOpenTaskDetail(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Info size={12} />
</button>
)}
</div>
</div>
))}
{!isLoading && tasks.length === 0 && (
<div className="text-sm text-gray-500 text-center py-12"></div>
)}
</div>
</div>
<div className="bg-[#111] border border-white/5 rounded-xl p-6 min-h-[400px]">
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6"></h2>
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-[11px] before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-white/10 before:to-transparent">
{isLoading && (
<div className="text-sm text-gray-500 text-center py-12">...</div>
)}
{activityLog.map((log) => (
<div key={log.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
<div className="flex items-center justify-center w-6 h-6 rounded-full border border-white/10 bg-[#111] group-[.is-active]:bg-cyan-500 group-[.is-active]:border-cyan-400 text-slate-500 group-[.is-active]:text-black shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 z-10" />
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] bg-[#0d0d0d] p-3 rounded border border-white/5">
<div className="text-xs text-gray-400 mb-1">{formatActivityTime(log.time)}</div>
<div className="text-sm font-medium text-gray-200">{log.message}</div>
<div className="text-xs text-gray-500">: {log.project}</div>
</div>
</div>
))}
{!isLoading && activityLog.length === 0 && (
<div className="text-sm text-gray-500 text-center py-12"></div>
)}
</div>
</div>
</div>
{selectedTask && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-2xl rounded-lg border border-white/10 bg-[#111] p-5 shadow-2xl">
<div className="flex items-center justify-between gap-3 border-b border-white/10 pb-3">
<div>
<h3 className="text-sm font-semibold text-white"> #{selectedTask.id}</h3>
<p className="mt-1 text-xs text-gray-500">{selectedTask.message || selectedTask.status}</p>
</div>
<button
onClick={() => setSelectedTask(null)}
className="rounded border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 hover:bg-white/10"
>
</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-gray-400">
<div>: <span className="text-gray-200">{selectedTask.status}</span></div>
<div>: <span className="text-gray-200">{selectedTask.progress}%</span></div>
<div> ID: <span className="text-gray-200">{selectedTask.project_id ?? '-'}</span></div>
<div>Celery ID: <span className="text-gray-200">{selectedTask.celery_task_id || '-'}</span></div>
<div>: <span className="text-gray-200">{selectedTask.created_at}</span></div>
<div>: <span className="text-gray-200">{selectedTask.finished_at || '-'}</span></div>
</div>
{selectedTask.error && (
<div className="mt-4 rounded border border-red-500/20 bg-red-500/10 p-3 text-xs text-red-200">
{selectedTask.error}
</div>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
<pre className="max-h-48 overflow-auto rounded border border-white/10 bg-[#0a0a0a] p-3 text-[11px] text-gray-300">
{JSON.stringify(selectedTask.payload || {}, null, 2)}
</pre>
<pre className="max-h-48 overflow-auto rounded border border-white/10 bg-[#0a0a0a] p-3 text-[11px] text-gray-300">
{JSON.stringify(selectedTask.result || {}, null, 2)}
</pre>
</div>
</div>
</div>
)}
</div>
);
}