Files
Pre_Seg_Server/src/components/ProjectLibrary.tsx
admin d583b32221 更新品牌文案与演示项目名称
- 登录页和侧栏统一使用根目录 logo_square.png,并更新登录系统名称与副标题。

- 更新 Dashboard、项目库和工作区时间轴文案,移除底层时序视频图层说明。

- 演示视频项目显示名改为“演视LC视频序列”,启动时兼容迁移旧 Data_MyVideo_1 名称,恢复出厂设置使用新名。

- 调整侧栏用户管理入口为用户图标,底部当前用户入口为退出图标,并让退出提示不接收鼠标事件。

- 补充前端组件测试、后端演示重置测试和文档说明。
2026-05-07 15:14:53 +08:00

984 lines
42 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, 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<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 [deleteProjectTarget, setDeleteProjectTarget] = useState<Project | null>(null);
const [copyingProjectId, setCopyingProjectId] = useState<string | null>(null);
const [copyProjectTarget, setCopyProjectTarget] = useState<Project | null>(null);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const [editingProjectName, setEditingProjectName] = useState('');
const [renamingProjectId, setRenamingProjectId] = useState<string | null>(null);
const [notice, setNotice] = useState<NoticeState | null>(null);
const [importProgress, setImportProgress] = useState<ImportProgressState | 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 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 (
<div
aria-live="polite"
aria-label="导入进度"
className={cn('mb-6 rounded-lg border px-4 py-3 shadow-lg shadow-black/20', toneClass)}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-gray-100">
{importProgress.phase === 'done' ? (
<Check size={16} className="text-emerald-300" />
) : importProgress.phase === 'error' ? (
<X size={16} className="text-red-300" />
) : (
<Loader2 size={16} className="animate-spin text-cyan-300" />
)}
<span>{importProgress.title}</span>
{importProgress.fileCount && (
<span className="rounded border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] font-mono text-gray-400">
{importProgress.fileCount}
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-gray-400">{importProgress.detail}</div>
</div>
{percent !== undefined && (
<div className="shrink-0 font-mono text-sm text-gray-200">{percent}%</div>
)}
</div>
<div
role="progressbar"
aria-label="导入进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="mt-3 h-2 overflow-hidden rounded-full bg-black/35"
>
<div
className={cn(
'h-full rounded-full transition-all duration-200',
barClass,
percent === undefined && 'w-1/3 animate-pulse',
)}
style={percent !== undefined ? { width: `${percent}%` } : undefined}
/>
</div>
</div>
);
};
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 = () => (
<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">DICOM序列文件</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>
<ImportProgressPanel />
{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">
{editingProjectId === proj.id ? (
<form
className="flex min-w-0 flex-1 items-center gap-1 pr-2"
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => void commitRenameProject(proj, event)}
>
<input
value={editingProjectName}
onChange={(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"
/>
<button
type="button"
aria-label={`保存项目名称 ${proj.name}`}
title="保存名称"
disabled={renamingProjectId === proj.id}
onClick={(event) => void commitRenameProject(proj, event)}
className="text-cyan-300 hover:text-cyan-100 disabled:cursor-wait disabled:opacity-50"
>
{renamingProjectId === proj.id ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
</button>
<button
type="button"
aria-label={`取消修改项目名称 ${proj.name}`}
title="取消"
onClick={cancelRenameProject}
disabled={renamingProjectId === proj.id}
className="text-gray-500 hover:text-gray-200 disabled:opacity-50"
>
<X size={15} />
</button>
</form>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2 pr-2">
<h3 className="truncate text-sm font-medium text-gray-200" title={proj.name}>{proj.name}</h3>
<button
type="button"
aria-label={`修改项目名称 ${proj.name}`}
title="修改项目名称"
onClick={(event) => beginRenameProject(proj, event)}
className="shrink-0 text-gray-500 opacity-0 transition-colors hover:text-cyan-300 group-hover:opacity-100 focus:opacity-100"
>
<Pencil size={14} />
</button>
</div>
)}
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label={`复制项目 ${proj.name}`}
title="复制项目"
disabled={copyingProjectId === proj.id || deletingProjectId === proj.id || renamingProjectId === proj.id}
onClick={(event) => openCopyProject(proj, event)}
className="text-gray-500 hover:text-emerald-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{copyingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Copy size={16} />}
</button>
<button
type="button"
aria-label={`删除项目 ${proj.name}`}
title="删除项目"
disabled={deletingProjectId === proj.id || renamingProjectId === proj.id}
onClick={(event) => openDeleteProject(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>
<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>
{canGenerateFrames(proj) && (
<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>
)}
{/* Delete project confirmation */}
{deleteProjectTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div
className="w-full max-w-md rounded-2xl border border-red-500/20 bg-[#111] p-6 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-3 text-sm leading-6 text-gray-400">
<span className="text-gray-100">{deleteProjectTarget.name}</span>
mask
</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setDeleteProjectTarget(null)}
disabled={deletingProjectId === deleteProjectTarget.id}
className="rounded-lg px-4 py-2 text-sm text-gray-400 transition-colors hover:text-white disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void handleDeleteProject()}
disabled={deletingProjectId === deleteProjectTarget.id}
className="inline-flex items-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-400 disabled:cursor-wait disabled:opacity-60"
>
{deletingProjectId === deleteProjectTarget.id && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
{/* Copy project modal */}
{copyProjectTarget && (
<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"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-lg font-semibold text-white mb-2"></h2>
<p className="text-sm text-gray-400 mb-5">
{copyProjectTarget.name}
</p>
<div className="space-y-3">
<button
type="button"
onClick={() => void handleCopyProject('reset')}
disabled={copyingProjectId === copyProjectTarget.id}
className="w-full rounded-lg border border-cyan-500/25 bg-cyan-500/10 px-4 py-3 text-left transition-colors hover:bg-cyan-500/20 disabled:cursor-wait disabled:opacity-60"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-cyan-100"></span>
{copyingProjectId === copyProjectTarget.id && <Loader2 size={16} className="animate-spin text-cyan-200" />}
</div>
<p className="mt-1 text-xs leading-5 text-gray-500"> mask </p>
</button>
<button
type="button"
onClick={() => void handleCopyProject('full')}
disabled={copyingProjectId === copyProjectTarget.id}
className="w-full rounded-lg border border-emerald-500/25 bg-emerald-500/10 px-4 py-3 text-left transition-colors hover:bg-emerald-500/20 disabled:cursor-wait disabled:opacity-60"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-emerald-100"></span>
{copyingProjectId === copyProjectTarget.id && <Loader2 size={16} className="animate-spin text-emerald-200" />}
</div>
<p className="mt-1 text-xs leading-5 text-gray-500"> mask </p>
</button>
</div>
<div className="flex justify-end mt-5">
<button
type="button"
onClick={() => setCopyProjectTarget(null)}
disabled={copyingProjectId === copyProjectTarget.id}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
>
</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>
);
}