import React, { useState, useEffect, useRef } from 'react'; import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react'; import { cn } from '../lib/utils'; import { useStore } from '../store/useStore'; import { getProjects, createProject, updateProject, copyProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject, getTask } from '../lib/api'; import type { UploadProgress } from '../lib/api'; import type { Project } from '../store/useStore'; import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice'; const naturalFilenameCompare = (left: File, right: File) => left.name.localeCompare( right.name, undefined, { numeric: true, sensitivity: 'base' }, ); interface ProjectLibraryProps { onProjectSelect: () => void; } interface ImportProgressState { kind: 'video' | 'dicom'; phase: 'preparing' | 'uploading' | 'queueing' | 'parsing' | 'done' | 'error'; title: string; detail: string; percent?: number; fileCount?: number; } 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(null); const [frameProject, setFrameProject] = useState(null); const [showFrameConfig, setShowFrameConfig] = useState(false); const [frameParseFps, setFrameParseFps] = useState(30); const [isGeneratingFrames, setIsGeneratingFrames] = useState(false); const [deletingProjectId, setDeletingProjectId] = useState(null); const [deleteProjectTarget, setDeleteProjectTarget] = useState(null); const [copyingProjectId, setCopyingProjectId] = useState(null); const [copyProjectTarget, setCopyProjectTarget] = useState(null); const [editingProjectId, setEditingProjectId] = useState(null); const [editingProjectName, setEditingProjectName] = useState(''); const [renamingProjectId, setRenamingProjectId] = useState(null); const [notice, setNotice] = useState(null); const [importProgress, setImportProgress] = useState(null); const videoInputRef = useRef(null); const dicomInputRef = useRef(null); const showNotice = (message: string, tone: NoticeTone = 'info') => { setNotice({ id: Date.now(), message, tone }); }; const formatUploadBytes = (value: number) => { if (!Number.isFinite(value) || value <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); const amount = value / (1024 ** index); return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`; }; const scheduleProgressDismiss = () => { window.setTimeout(() => setImportProgress((current) => ( current?.phase === 'done' ? null : current )), 1400); }; const uploadProgressDetail = (progress: UploadProgress, fallback: string) => { if (progress.total) { return `${formatUploadBytes(progress.loaded)} / ${formatUploadBytes(progress.total)}`; } return `${fallback},已上传 ${formatUploadBytes(progress.loaded)}`; }; const waitForTaskDone = async ( taskId: string | number, onProgress: (progress: { progress?: number; message?: string | null; status?: string }) => void, ) => { for (;;) { await new Promise((resolve) => window.setTimeout(resolve, 1200)); const task = await getTask(taskId); onProgress(task); if (['success', 'failed', 'cancelled'].includes(task.status)) return task; } }; const refreshProjects = async () => { const data = await getProjects(); setProjects(data); if (currentProject?.id) { const refreshedCurrentProject = data.find((project) => project.id === currentProject.id); if (refreshedCurrentProject) { setCurrentProject(refreshedCurrentProject); } } return data; }; const ImportProgressPanel = () => { if (!importProgress) return null; const percent = typeof importProgress.percent === 'number' ? Math.min(100, Math.max(0, importProgress.percent)) : undefined; const toneClass = importProgress.phase === 'error' ? 'border-red-500/25 bg-red-950/20' : importProgress.kind === 'dicom' ? 'border-emerald-500/25 bg-emerald-950/15' : 'border-cyan-500/25 bg-cyan-950/15'; const barClass = importProgress.phase === 'error' ? 'bg-red-400' : importProgress.kind === 'dicom' ? 'bg-emerald-400' : 'bg-cyan-400'; return (
{importProgress.phase === 'done' ? ( ) : importProgress.phase === 'error' ? ( ) : ( )} {importProgress.title} {importProgress.fileCount && ( {importProgress.fileCount} 文件 )}
{importProgress.detail}
{percent !== undefined && (
{percent}%
)}
); }; 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'; }; const canGenerateFrames = (project: Project) => ( project.source_type !== 'dicom' && Boolean(project.video_path) && (project.frames ?? 0) === 0 && project.status !== 'parsing' && editingProjectId !== project.id ); 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 openDeleteProject = (project: Project, event: React.MouseEvent) => { event.stopPropagation(); if (deletingProjectId) return; setDeleteProjectTarget(project); }; const handleDeleteProject = async () => { const project = deleteProjectTarget; if (!project || deletingProjectId) 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([]); } setDeleteProjectTarget(null); } catch (err) { console.error('Delete project failed:', err); showNotice('删除项目失败,请检查后端服务', 'error'); } finally { setDeletingProjectId(null); } }; const openCopyProject = (project: Project, event: React.MouseEvent) => { event.stopPropagation(); setCopyProjectTarget(project); }; const handleCopyProject = async (mode: 'reset' | 'full') => { if (!copyProjectTarget || copyingProjectId) return; setCopyingProjectId(copyProjectTarget.id); try { const copied = await copyProject(copyProjectTarget.id, { mode }); const data = await getProjects(); setProjects(data); setCopyProjectTarget(null); showNotice(mode === 'full' ? `已全内容复制项目:${copied.name}` : `已复制为重置项目:${copied.name}`, 'success'); } catch (err) { console.error('Copy project failed:', err); showNotice('复制项目失败,请检查后端服务', 'error'); } finally { setCopyingProjectId(null); } }; const beginRenameProject = (project: Project, event: React.MouseEvent) => { event.stopPropagation(); setEditingProjectId(project.id); setEditingProjectName(project.name); }; const cancelRenameProject = (event: React.MouseEvent) => { event.stopPropagation(); setEditingProjectId(null); setEditingProjectName(''); }; const commitRenameProject = async (project: Project, event?: React.SyntheticEvent) => { event?.preventDefault(); event?.stopPropagation(); const nextName = editingProjectName.trim(); if (!nextName) { showNotice('项目名称不能为空', 'error'); return; } if (nextName === project.name) { setEditingProjectId(null); setEditingProjectName(''); return; } setRenamingProjectId(project.id); try { const updated = await updateProject(project.id, { name: nextName }); setProjects(projects.map((item) => (item.id === updated.id ? updated : item))); if (currentProject?.id === updated.id) { setCurrentProject(updated); } setEditingProjectId(null); setEditingProjectName(''); showNotice('项目名称已更新', 'success'); } catch (err) { console.error('Rename project failed:', err); showNotice('项目名称修改失败,请检查后端服务', 'error'); } finally { setRenamingProjectId(null); } }; const handleVideoSelect = (file: File) => { setPendingFile(file); setShowVideoConfig(true); }; const handleVideoUpload = async () => { if (!pendingFile) return; setShowVideoConfig(false); setIsLoading(true); setImportProgress({ kind: 'video', phase: 'preparing', title: '正在准备视频导入', detail: `创建项目:${pendingFile.name}`, percent: 2, }); try { const newProject = await createProject({ name: pendingFile.name, description: `导入于 ${new Date().toLocaleString()}`, }); setImportProgress({ kind: 'video', phase: 'uploading', title: '正在上传视频文件', detail: pendingFile.name, percent: 5, }); const result = await uploadMedia(pendingFile, String(newProject.id), { onProgress: (progress) => setImportProgress({ kind: 'video', phase: 'uploading', title: '正在上传视频文件', detail: uploadProgressDetail(progress, pendingFile.name), percent: progress.percent, }), }); setImportProgress({ kind: 'video', phase: 'done', title: '视频导入完成', detail: pendingFile.name, percent: 100, }); showNotice(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时,请在项目卡片点击“生成帧”。`, 'success'); const data = await getProjects(); setProjects(data); scheduleProgressDismiss(); } catch (err) { console.error('Upload failed:', err); setImportProgress({ kind: 'video', phase: 'error', title: '视频导入失败', detail: pendingFile.name, percent: 100, }); 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; const targetProject = frameProject; setIsGeneratingFrames(true); try { const task = await parseMedia(targetProject.id, { parseFps: frameParseFps }); showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success'); await refreshProjects(); setShowFrameConfig(false); setFrameProject(null); if (task.id) { setImportProgress({ kind: 'video', phase: 'parsing', title: '正在生成视频帧', detail: task.message || `项目:${targetProject.name}`, percent: task.progress ?? 0, }); void waitForTaskDone(task.id, (progress) => { setImportProgress({ kind: 'video', phase: 'parsing', title: '正在生成视频帧', detail: progress.message || `任务状态: ${progress.status || 'running'}`, percent: progress.progress, }); }).then(async (completed) => { if (completed.status === 'failed') { setImportProgress({ kind: 'video', phase: 'error', title: '视频帧生成失败', detail: completed.error || completed.message || targetProject.name, percent: 100, }); showNotice('视频帧生成失败,请检查后端服务或任务日志', 'error'); return; } if (completed.status === 'cancelled') { setImportProgress({ kind: 'video', phase: 'error', title: '视频帧生成已取消', detail: targetProject.name, percent: 100, }); showNotice('视频帧生成任务已取消', 'error'); return; } await refreshProjects(); setImportProgress({ kind: 'video', phase: 'done', title: '视频帧生成完成', detail: '项目封面已自动更新', percent: 100, }); showNotice('视频帧生成完成,项目封面已自动更新', 'success'); scheduleProgressDismiss(); }).catch((err) => { console.error('Frame generation polling failed:', err); setImportProgress({ kind: 'video', phase: 'error', title: '视频帧生成进度同步失败', detail: targetProject.name, percent: 100, }); showNotice('视频帧生成进度同步失败,请刷新项目库重试', 'error'); }); } } 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')) .sort(naturalFilenameCompare); if (dcmFiles.length === 0) { showNotice('未选择有效的 .dcm 文件', 'error'); return; } setIsLoading(true); setImportProgress({ kind: 'dicom', phase: 'uploading', title: '正在上传 DICOM 序列', detail: `${dcmFiles.length} 个文件,按文件名自然顺序上传`, percent: 0, fileCount: dcmFiles.length, }); try { const result = await uploadDicomBatch(dcmFiles, undefined, { onProgress: (progress) => setImportProgress({ kind: 'dicom', phase: 'uploading', title: '正在上传 DICOM 序列', detail: uploadProgressDetail(progress, `${dcmFiles.length} 个文件`), percent: progress.percent, fileCount: dcmFiles.length, }), }); setImportProgress({ kind: 'dicom', phase: 'queueing', title: 'DICOM 上传完成,正在创建解析任务', detail: `${result.uploaded_count} 个文件已上传`, percent: 92, fileCount: result.uploaded_count, }); const task = await parseMedia(String(result.project_id)); setImportProgress({ kind: 'dicom', phase: 'parsing', title: '正在解析 DICOM 序列', detail: task.message || '解析任务已入队', percent: task.progress ?? 0, fileCount: result.uploaded_count, }); if (task.id) { const completed = await waitForTaskDone(task.id, (progress) => { setImportProgress({ kind: 'dicom', phase: 'parsing', title: '正在解析 DICOM 序列', detail: progress.message || `任务状态: ${progress.status || 'running'}`, percent: progress.progress, fileCount: result.uploaded_count, }); }); if (completed.status === 'failed') { throw new Error(completed.error || completed.message || 'DICOM 解析失败'); } if (completed.status === 'cancelled') { throw new Error('DICOM 解析任务已取消'); } } setImportProgress({ kind: 'dicom', phase: 'done', title: 'DICOM 导入完成', detail: `${result.uploaded_count} 个文件已上传并完成解析`, percent: 100, fileCount: result.uploaded_count, }); showNotice(`DICOM 导入完成: ${result.uploaded_count} 个文件`, 'success'); const data = await getProjects(); setProjects(data); scheduleProgressDismiss(); } catch (err) { console.error('DICOM upload failed:', err); setImportProgress({ kind: 'dicom', phase: 'error', title: 'DICOM 导入失败', detail: `${dcmFiles.length} 个文件`, percent: 100, fileCount: dcmFiles.length, }); showNotice('DICOM 上传失败,请检查后端服务', 'error'); } finally { setIsLoading(false); if (dicomInputRef.current) dicomInputRef.current.value = ''; } }; const SkeletonCard = () => (
); return (
setNotice(null)} />

视频与连续帧项目库

支持导入视频文件、DICOM序列文件

{showImportMenu && (
)}
{ const file = e.target.files?.[0]; if (file) handleVideoSelect(file); }} /> handleDicomUpload(e.target.files)} />
{isLoading && projects.length === 0 ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : (
{projects.map((proj) => (
handleSelect(proj)} >
{proj.thumbnail_url ? ( {proj.name} ) : ( )}
{frameSequenceLabel(proj)} {proj.status === 'ready' ? ( <>
已就绪 ) : proj.status === 'parsing' ? ( <>
解析拆帧中 ) : proj.status === 'error' ? ( <>
异常 ) : ( <>
待处理 )}
{editingProjectId === proj.id ? (
event.stopPropagation()} onSubmit={(event) => void commitRenameProject(proj, event)} > setEditingProjectName(event.target.value)} autoFocus className="min-w-0 flex-1 rounded border border-cyan-400/40 bg-black/30 px-2 py-1 text-sm text-gray-100 outline-none focus:border-cyan-300" />
) : (

{proj.name}

)}
{proj.frames ?? 0} 帧节点 {proj.original_fps && ( 原 {proj.original_fps.toFixed(1)}fps )}
{canGenerateFrames(proj) && ( )}
))}
)} {/* Delete project confirmation */} {deleteProjectTarget && (
event.stopPropagation()} >

删除项目

确认删除项目“{deleteProjectTarget.name}”? 该操作会删除项目帧、标注、任务记录和相关 mask 元数据,无法撤销。

)} {/* Copy project modal */} {copyProjectTarget && (
event.stopPropagation()} >

复制项目

{copyProjectTarget.name}

)} {/* Video parse FPS config modal */} {showVideoConfig && pendingFile && (

导入视频文件

文件: {pendingFile.name}

此步骤只上传源视频并创建项目,不会立即拆帧。拆帧时再选择目标 FPS。

)} {/* Frame generation FPS config modal */} {showFrameConfig && frameProject && (

生成帧

项目: {frameProject.name}
setFrameParseFps(parseInt(e.target.value))} className="flex-1 accent-cyan-500" /> {frameParseFps}

帧率越低,提取的帧越少,处理速度越快

)} {/* New project modal */} {showModal && (

新建项目

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="输入项目名称" />
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="输入项目描述" />
)}
); }