Files
Pre_Seg_Server/src/components/ProjectLibrary.tsx
admin c8c59f7ede feat: 完善分割工作区传播与交互闭环
功能增加:新增后端传播任务执行器,支持异步自动传播、传播进度、结果统计、取消/重试状态同步。

功能增加:传播请求支持指定 SAM2.1 tiny/small/base+/large 权重,并记录 seed mask、source annotation 和传播范围。

功能增加:传播逻辑增加 seed 签名,未变化的 mask 二次传播会跳过,已变化的 mask 会先清理旧自动传播结果再重新生成,避免重复重叠。

功能增加:工作区增加传播范围二次选择、传播进度提示、人工/AI 标注帧红色标识、自动传播帧蓝色标识和当前帧双层边框。

功能增加:新增临时提示组件,让工具操作提示自动消失且不阻塞后续操作。

功能增加:补充项目删除、模板删除、任务失败详情、任务取消/重试等前后端联动状态。

功能增加:新增安装部署文档,补充当前需求冻结、设计冻结、接口契约、测试计划和 AGENTS/README 项目说明。

Bugfix:修复自动传播接口 404、传播后看不到任务进度、传播结果重复堆叠和已编辑帧提示不清晰的问题。

Bugfix:修复 AI 分割框选/点选交互、单候选 mask、删除选点、工作区保存与候选 mask 推送相关问题。

Bugfix:修复 Canvas 多边形顶点拖动告警、工具栏提示缺失、项目库 FPS 展示和若干 UI 文案/可用性问题。

测试:补充 AI 分割、Canvas、Dashboard、FrameTimeline、ProjectLibrary、TemplateRegistry、ToolsPalette、VideoWorkspace、API 和后端任务/AI/dashboard 测试。

验证:npm run lint;npm run test:run;python -m pytest backend/tests -q。
2026-05-02 05:17:18 +08:00

457 lines
21 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, useRef } from 'react';
import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject } from '../lib/api';
import type { Project } from '../store/useStore';
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
interface ProjectLibraryProps {
onProjectSelect: () => void;
}
export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const projects = useStore((state) => state.projects);
const setProjects = useStore((state) => state.setProjects);
const currentProject = useStore((state) => state.currentProject);
const setCurrentProject = useStore((state) => state.setCurrentProject);
const addProject = useStore((state) => state.addProject);
const setFrames = useStore((state) => state.setFrames);
const setMasks = useStore((state) => state.setMasks);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [showModal, setShowModal] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [showImportMenu, setShowImportMenu] = useState(false);
const [showVideoConfig, setShowVideoConfig] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [frameProject, setFrameProject] = useState<Project | null>(null);
const [showFrameConfig, setShowFrameConfig] = useState(false);
const [frameParseFps, setFrameParseFps] = useState(30);
const [isGeneratingFrames, setIsGeneratingFrames] = useState(false);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [notice, setNotice] = useState<NoticeState | null>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const dicomInputRef = useRef<HTMLInputElement>(null);
const showNotice = (message: string, tone: NoticeTone = 'info') => {
setNotice({ id: Date.now(), message, tone });
};
const frameSequenceLabel = (project: Project) => {
if (project.source_type === 'dicom') return 'DICOM';
if (project.video_path && (project.frames ?? 0) === 0 && project.status !== 'parsing') return '待生成帧';
if (project.parse_fps && project.parse_fps > 0) {
const rounded = Math.round(project.parse_fps * 10) / 10;
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}FPS`;
}
return project.fps || '30FPS';
};
useEffect(() => {
setIsLoading(true);
getProjects()
.then((data) => setProjects(data))
.catch(console.error)
.finally(() => setIsLoading(false));
}, [setProjects]);
const handleCreate = async () => {
if (!newName.trim()) return;
setIsCreating(true);
try {
const project = await createProject({ name: newName.trim(), description: newDesc.trim() || undefined });
addProject(project);
setShowModal(false);
setNewName('');
setNewDesc('');
} catch (err) {
console.error('Failed to create project:', err);
} finally {
setIsCreating(false);
}
};
const handleSelect = (project: Project) => {
setCurrentProject(project);
onProjectSelect();
};
const handleDeleteProject = async (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
if (deletingProjectId) return;
const confirmed = window.confirm(`确认删除项目“${project.name}”?\n该操作会删除项目帧、标注、任务记录和相关 mask 元数据,无法撤销。`);
if (!confirmed) return;
setDeletingProjectId(project.id);
try {
await deleteProject(project.id);
setProjects(projects.filter((item) => item.id !== project.id));
if (currentProject?.id === project.id) {
setCurrentProject(null);
setFrames([]);
setMasks([]);
setSelectedMaskIds([]);
}
} catch (err) {
console.error('Delete project failed:', err);
showNotice('删除项目失败,请检查后端服务', 'error');
} finally {
setDeletingProjectId(null);
}
};
const handleVideoSelect = (file: File) => {
setPendingFile(file);
setShowVideoConfig(true);
};
const handleVideoUpload = async () => {
if (!pendingFile) return;
setShowVideoConfig(false);
setIsLoading(true);
try {
const newProject = await createProject({
name: pendingFile.name,
description: `导入于 ${new Date().toLocaleString()}`,
});
const result = await uploadMedia(pendingFile, String(newProject.id));
showNotice(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时请在项目卡片点击“生成帧”。`, 'success');
const data = await getProjects();
setProjects(data);
} catch (err) {
console.error('Upload failed:', err);
showNotice('上传失败,请检查后端服务', 'error');
} finally {
setIsLoading(false);
setPendingFile(null);
if (videoInputRef.current) videoInputRef.current.value = '';
}
};
const openFrameConfig = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
setFrameProject(project);
setFrameParseFps(Math.round(project.parse_fps || 30));
setShowFrameConfig(true);
};
const handleGenerateFrames = async () => {
if (!frameProject?.id) return;
setIsGeneratingFrames(true);
try {
const task = await parseMedia(frameProject.id, { parseFps: frameParseFps });
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
const data = await getProjects();
setProjects(data);
setShowFrameConfig(false);
setFrameProject(null);
} catch (err) {
console.error('Frame generation failed:', err);
showNotice('生成帧失败,请检查后端服务或项目源文件', 'error');
} finally {
setIsGeneratingFrames(false);
}
};
const handleDicomUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const dcmFiles = Array.from(files).filter((f) => f.name.toLowerCase().endsWith('.dcm'));
if (dcmFiles.length === 0) {
showNotice('未选择有效的 .dcm 文件', 'error');
return;
}
setIsLoading(true);
try {
const result = await uploadDicomBatch(dcmFiles);
await parseMedia(String(result.project_id));
showNotice(`DICOM 上传成功: ${result.uploaded_count} 个文件`, 'success');
const data = await getProjects();
setProjects(data);
} catch (err) {
console.error('DICOM upload failed:', err);
showNotice('DICOM 上传失败,请检查后端服务', 'error');
} finally {
setIsLoading(false);
if (dicomInputRef.current) dicomInputRef.current.value = '';
}
};
const SkeletonCard = () => (
<div className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden animate-pulse">
<div className="w-full aspect-[16/9] bg-[#1a1a1a]" />
<div className="p-4 flex flex-col gap-2">
<div className="h-4 bg-[#1a1a1a] rounded w-3/4" />
<div className="h-3 bg-[#1a1a1a] rounded w-1/2 mt-2" />
</div>
</div>
);
return (
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
<div className="flex justify-between items-end mb-8 border-b border-white/5 pb-6">
<div>
<h1 className="text-3xl font-medium tracking-tight text-white mb-2"></h1>
<p className="text-gray-400 text-sm"></p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 text-gray-200 px-5 py-2.5 rounded-lg font-medium text-sm transition-colors"
>
<Plus size={18} />
<span></span>
</button>
<div className="relative">
<button
onClick={() => setShowImportMenu(!showImportMenu)}
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white px-5 py-2.5 rounded-lg font-medium text-sm transition-colors border border-cyan-500 shadow-lg shadow-cyan-900/20"
>
<UploadCloud size={18} />
<span></span>
</button>
{showImportMenu && (
<div className="absolute right-0 top-full mt-2 w-56 bg-[#111] border border-white/10 rounded-lg shadow-2xl z-50 overflow-hidden">
<button
className="w-full text-left px-4 py-3 text-sm text-gray-200 hover:bg-white/5 flex items-center gap-3 transition-colors"
onClick={() => { setShowImportMenu(false); videoInputRef.current?.click(); }}
>
<Film size={16} className="text-cyan-400" />
</button>
<button
className="w-full text-left px-4 py-3 text-sm text-gray-200 hover:bg-white/5 flex items-center gap-3 transition-colors border-t border-white/5"
onClick={() => { setShowImportMenu(false); dicomInputRef.current?.click(); }}
>
<Activity size={16} className="text-emerald-400" />
DICOM
</button>
</div>
)}
</div>
<input
type="file"
ref={videoInputRef}
className="hidden"
accept="video/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleVideoSelect(file);
}}
/>
<input
type="file"
ref={dicomInputRef}
className="hidden"
accept=".dcm"
multiple
onChange={(e) => handleDicomUpload(e.target.files)}
/>
</div>
</div>
{isLoading && projects.length === 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{projects.map((proj) => (
<div
key={proj.id}
className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden cursor-pointer hover:border-cyan-500/50 transition-all hover:shadow-[0_0_20px_rgba(6,182,212,0.15)]"
onClick={() => handleSelect(proj)}
>
<div className={cn("w-full aspect-[16/9] relative flex items-center justify-center overflow-hidden bg-[#0d0d0d]")}>
{proj.thumbnail_url ? (
<img
src={proj.thumbnail_url}
alt={proj.name}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
) : (
<Film className="w-12 h-12 text-[#2a2a2a] group-hover:text-[#333] transition-colors" />
)}
<div className="absolute top-2 right-2 flex gap-2">
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
{frameSequenceLabel(proj)}
</span>
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
{proj.status === 'ready' ? (
<><div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> </>
) : proj.status === 'parsing' ? (
<><div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" /> </>
) : proj.status === 'error' ? (
<><div className="w-1.5 h-1.5 bg-red-500 rounded-full" /> </>
) : (
<><div className="w-1.5 h-1.5 bg-blue-500 rounded-full" /> </>
)}
</span>
</div>
</div>
<div className="p-4 flex flex-col gap-1">
<div className="flex justify-between items-start">
<h3 className="text-sm font-medium text-gray-200 truncate pr-4" title={proj.name}>{proj.name}</h3>
<button
type="button"
aria-label={`删除项目 ${proj.name}`}
title="删除项目"
disabled={deletingProjectId === proj.id}
onClick={(event) => handleDeleteProject(proj, event)}
className="text-gray-500 hover:text-red-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{deletingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</button>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono mt-2">
<span className="flex items-center gap-1.5"><Settings2 size={12} /> {proj.frames ?? 0} </span>
{proj.original_fps && (
<span className="flex items-center gap-1.5 text-cyan-400/80"><Activity size={12} /> {proj.original_fps.toFixed(1)}fps</span>
)}
</div>
{proj.video_path && (proj.frames ?? 0) === 0 && proj.status !== 'parsing' && (
<button
onClick={(event) => openFrameConfig(proj, event)}
className="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-200 hover:bg-cyan-500/20 transition-colors"
>
<Images size={14} />
</button>
)}
</div>
</div>
))}
</div>
)}
{/* Video parse FPS config modal */}
{showVideoConfig && pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="space-y-4">
<div className="text-sm text-gray-400">: <span className="text-gray-200">{pendingFile.name}</span></div>
<p className="text-xs leading-5 text-gray-500"> FPS</p>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => { setShowVideoConfig(false); setPendingFile(null); }}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
>
</button>
<button
onClick={handleVideoUpload}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* Frame generation FPS config modal */}
{showFrameConfig && frameProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="space-y-4">
<div className="text-sm text-gray-400">: <span className="text-gray-200">{frameProject.name}</span></div>
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"> (FPS)</label>
<div className="flex items-center gap-3">
<input
type="range"
min="1"
max="60"
value={frameParseFps}
onChange={(e) => setFrameParseFps(parseInt(e.target.value))}
className="flex-1 accent-cyan-500"
/>
<span className="text-sm font-mono text-cyan-400 w-12 text-right">{frameParseFps}</span>
</div>
<p className="text-[10px] text-gray-600 mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => { setShowFrameConfig(false); setFrameProject(null); }}
disabled={isGeneratingFrames}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleGenerateFrames}
disabled={isGeneratingFrames}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all disabled:opacity-60"
>
{isGeneratingFrames ? '入队中...' : '开始生成帧'}
</button>
</div>
</div>
</div>
)}
{/* New project modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"></label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
placeholder="输入项目名称"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"></label>
<input
type="text"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
placeholder="输入项目描述"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => { setShowModal(false); setNewName(''); setNewDesc(''); }}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
>
</button>
<button
onClick={handleCreate}
disabled={isCreating || !newName.trim()}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all",
isCreating || !newName.trim()
? "bg-cyan-500/50 text-black/70 cursor-not-allowed"
: "bg-cyan-500 hover:bg-cyan-400 text-black"
)}
>
{isCreating && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
</div>
);
}