- 登录页和侧栏统一使用根目录 logo_square.png,并更新登录系统名称与副标题。 - 更新 Dashboard、项目库和工作区时间轴文案,移除底层时序视频图层说明。 - 演示视频项目显示名改为“演视LC视频序列”,启动时兼容迁移旧 Data_MyVideo_1 名称,恢复出厂设置使用新名。 - 调整侧栏用户管理入口为用户图标,底部当前用户入口为退出图标,并让退出提示不接收鼠标事件。 - 补充前端组件测试、后端演示重置测试和文档说明。
984 lines
42 KiB
TypeScript
984 lines
42 KiB
TypeScript
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>
|
||
);
|
||
}
|