2026-05-04-03-50-07 完善项目库可视化和项目管理
This commit is contained in:
@@ -1,53 +1,106 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
RotateCw,
|
||||
FileText,
|
||||
Box,
|
||||
Image as ImageIcon,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Trash2,
|
||||
Edit2,
|
||||
FolderRoot,
|
||||
Download
|
||||
Download,
|
||||
Layers,
|
||||
Save,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, Stage, Gltf, useGLTF, Environment, PerspectiveCamera } from '@react-three/drei';
|
||||
import { Project } from '../types';
|
||||
import { api } from '../lib/api';
|
||||
import { Canvas, useLoader } from '@react-three/fiber';
|
||||
import { Bounds, Center, OrbitControls, Stage } from '@react-three/drei';
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
import * as THREE from 'three';
|
||||
import { DicomPreview, Project } from '../types';
|
||||
import { api, downloadMask } from '../lib/api';
|
||||
|
||||
function StlModel({ url }: { url: string }) {
|
||||
const geometry = useLoader(STLLoader, url);
|
||||
|
||||
// Mock 3D Model component
|
||||
function ModelPreview() {
|
||||
return (
|
||||
<mesh>
|
||||
<boxGeometry args={[1.5, 1.5, 1.5]} />
|
||||
<meshStandardMaterial color="#3b82f6" metalness={0.5} roughness={0.2} />
|
||||
<mesh geometry={geometry as THREE.BufferGeometry}>
|
||||
<meshStandardMaterial color="#3b82f6" roughness={0.48} metalness={0.08} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function DicomCanvas({ preview }: { preview: DicomPreview }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const binary = atob(preview.pixels);
|
||||
const imageData = context.createImageData(preview.width, preview.height);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
const value = binary.charCodeAt(i);
|
||||
const offset = i * 4;
|
||||
imageData.data[offset] = value;
|
||||
imageData.data[offset + 1] = value;
|
||||
imageData.data[offset + 2] = value;
|
||||
imageData.data[offset + 3] = 255;
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}, [preview]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={preview.width}
|
||||
height={preview.height}
|
||||
className="max-h-full max-w-full object-contain rounded-xl shadow-2xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'dicom' | 'model'>('dicom');
|
||||
const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [sliceIndex, setSliceIndex] = useState(42);
|
||||
const [sliceIndex, setSliceIndex] = useState(0);
|
||||
const [visibleModules, setVisibleModules] = useState<Record<string, boolean>>({});
|
||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||
const [dicomError, setDicomError] = useState('');
|
||||
const [selectedModelFile, setSelectedModelFile] = useState('');
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [editingProjectId, setEditingProjectId] = useState('');
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.getProjects()
|
||||
const refreshProjects = () => {
|
||||
setLoading(true);
|
||||
return api.getProjects()
|
||||
.then((items) => {
|
||||
setProjects(items);
|
||||
setSelectedProject(items[0] ?? null);
|
||||
setSelectedProject((current) => {
|
||||
if (!current) {
|
||||
return items[0] ?? null;
|
||||
}
|
||||
return items.find((item) => item.id === current.id) ?? items[0] ?? null;
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects();
|
||||
}, []);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
@@ -68,7 +121,35 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
next[module] = visibleModules[module] ?? true;
|
||||
});
|
||||
setVisibleModules(next);
|
||||
setSelectedModelFile(selectedProject?.stlFiles?.[0] ?? '');
|
||||
setSliceIndex(0);
|
||||
}, [selectedProject?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) {
|
||||
setDicomPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDicomError('');
|
||||
api.getDicomPreview(selectedProject.id, sliceIndex)
|
||||
.then((preview) => {
|
||||
if (!cancelled) {
|
||||
setDicomPreview(preview);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setDicomPreview(null);
|
||||
setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, viewMode]);
|
||||
|
||||
const toggleModule = (name: string) => {
|
||||
setVisibleModules(prev => ({ ...prev, [name]: !prev[name] }));
|
||||
@@ -81,6 +162,39 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
setVisibleModules(newState);
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) {
|
||||
setActionMessage('请输入项目名称');
|
||||
return;
|
||||
}
|
||||
const created = await api.createProject(name);
|
||||
setNewProjectName('');
|
||||
setActionMessage(`已创建项目:${created.name}`);
|
||||
await refreshProjects();
|
||||
setSelectedProject(created);
|
||||
};
|
||||
|
||||
const handleRenameProject = async (projectId: string) => {
|
||||
const name = editingName.trim();
|
||||
if (!name) {
|
||||
setActionMessage('项目名称不能为空');
|
||||
return;
|
||||
}
|
||||
const updated = await api.renameProject(projectId, name);
|
||||
setEditingProjectId('');
|
||||
setEditingName('');
|
||||
setActionMessage(`已更新项目名称:${updated.name}`);
|
||||
await refreshProjects();
|
||||
setSelectedProject(updated);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon },
|
||||
{ id: 'model' as const, label: '3D 模型', icon: Box },
|
||||
{ id: 'mask' as const, label: '分割结果', icon: Layers },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-6 overflow-hidden">
|
||||
{/* Project Sidebar - Collapsible */}
|
||||
@@ -98,7 +212,36 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="p-4 flex flex-col h-full overflow-hidden">
|
||||
<h3 className="font-bold text-slate-800 mb-4 px-1">项目列表</h3>
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h3 className="font-bold text-slate-800">项目列表</h3>
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
className="p-1.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
|
||||
title="创建项目"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input
|
||||
value={newProjectName}
|
||||
onChange={(event) => setNewProjectName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleCreateProject();
|
||||
}
|
||||
}}
|
||||
placeholder="新项目名称"
|
||||
className="min-w-0 flex-1 px-3 py-2 bg-slate-50 border-none rounded-lg text-xs focus:ring-1 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
className="w-9 h-9 rounded-lg bg-slate-900 text-white flex items-center justify-center hover:bg-slate-700"
|
||||
title="保存新项目"
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||||
<input
|
||||
@@ -112,22 +255,77 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
||||
{loading && <p className="text-xs text-slate-400 px-2">正在从后端载入项目...</p>}
|
||||
{filteredProjects.map((proj) => (
|
||||
<button
|
||||
<div
|
||||
key={proj.id}
|
||||
onClick={() => setSelectedProject(proj)}
|
||||
className={`w-full p-3 rounded-xl transition-all text-left ${
|
||||
className={`w-full p-3 rounded-xl transition-all text-left cursor-pointer ${
|
||||
selectedProject?.id === proj.id ? 'bg-blue-600 text-white shadow-md' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{editingProjectId === proj.id ? (
|
||||
<input
|
||||
value={editingName}
|
||||
autoFocus
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => setEditingName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') handleRenameProject(proj.id);
|
||||
if (event.key === 'Escape') setEditingProjectId('');
|
||||
}}
|
||||
className="w-full rounded-md px-2 py-1 text-xs text-slate-900 outline-none ring-1 ring-blue-200"
|
||||
/>
|
||||
) : (
|
||||
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{editingProjectId === proj.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRenameProject(proj.id);
|
||||
}}
|
||||
className="text-emerald-500"
|
||||
title="保存名称"
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingProjectId('');
|
||||
}}
|
||||
className="text-slate-400"
|
||||
title="取消"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingProjectId(proj.id);
|
||||
setEditingName(proj.name);
|
||||
}}
|
||||
className={selectedProject?.id === proj.id ? 'text-blue-100 hover:text-white' : 'text-slate-300 hover:text-blue-600'}
|
||||
title="修改项目名称"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-[10px] mt-1 ${selectedProject?.id === proj.id ? 'text-blue-100' : 'text-slate-400'}`}>
|
||||
{proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{actionMessage && <p className="text-[10px] text-slate-400 mt-3 px-1">{actionMessage}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -154,22 +352,17 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
<button
|
||||
onClick={() => setViewMode('dicom')}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
|
||||
viewMode === 'dicom' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon size={16} /> DICOM 影像
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('model')}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
|
||||
viewMode === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Box size={16} /> 3D 模型
|
||||
</button>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setViewMode(tab.id)}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
|
||||
viewMode === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} /> {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
@@ -185,7 +378,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8">
|
||||
{viewMode === 'dicom' ? (
|
||||
{viewMode === 'dicom' && (
|
||||
<div className="h-full flex gap-8">
|
||||
{/* Left: DICOM Viewer */}
|
||||
<div className="flex-1 bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center p-12">
|
||||
@@ -195,12 +388,14 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<p>DICOM PATH: {selectedProject.dicomPath}</p>
|
||||
</div>
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
<div className="w-64 h-64 border-2 border-white/5 rounded-full absolute animate-pulse" />
|
||||
<div className="w-56 h-56 border border-white/10 rounded-full" />
|
||||
<p className="absolute text-white/20 text-xs font-mono uppercase tracking-widest">DCM RENDER VIEW | #{sliceIndex}</p>
|
||||
{dicomPreview ? (
|
||||
<DicomCanvas preview={dicomPreview} />
|
||||
) : (
|
||||
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
|
||||
<span>WW/WL: 400/40</span>
|
||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40}</span>
|
||||
<span>SLICE: {sliceIndex}/{selectedProject.dicomCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,14 +411,32 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{viewMode === 'model' && (
|
||||
<div className="h-full flex gap-8">
|
||||
{/* Left: 3D Visualization */}
|
||||
<div className="flex-1 bg-slate-50 rounded-2xl relative border border-slate-100 overflow-hidden">
|
||||
<Canvas>
|
||||
<Stage environment="city" intensity={0.5}>
|
||||
<ModelPreview />
|
||||
</Stage>
|
||||
<color attach="background" args={['#f8fafc']} />
|
||||
<ambientLight intensity={0.65} />
|
||||
<directionalLight position={[3, 6, 5]} intensity={1.1} />
|
||||
<Suspense fallback={null}>
|
||||
{selectedModelFile ? (
|
||||
<Bounds fit clip observe margin={1.25}>
|
||||
<Center>
|
||||
<StlModel url={`/api/projects/${selectedProject.id}/models/${encodeURIComponent(selectedModelFile)}`} />
|
||||
</Center>
|
||||
</Bounds>
|
||||
) : (
|
||||
<Stage environment="city" intensity={0.5}>
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#94a3b8" />
|
||||
</mesh>
|
||||
</Stage>
|
||||
)}
|
||||
</Suspense>
|
||||
<OrbitControls />
|
||||
</Canvas>
|
||||
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
|
||||
@@ -246,11 +459,12 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
{subModules.map((m, i) => (
|
||||
<div
|
||||
key={m}
|
||||
className={`p-3 rounded-xl border flex items-center gap-3 group transition-all ${
|
||||
i === 0 ? 'bg-blue-50 border-blue-100' : 'bg-slate-50 border-transparent hover:border-slate-200'
|
||||
onClick={() => setSelectedModelFile(selectedProject.stlFiles?.[i] ?? '')}
|
||||
className={`p-3 rounded-xl border flex items-center gap-3 group transition-all cursor-pointer ${
|
||||
selectedProject.stlFiles?.[i] === selectedModelFile ? 'bg-blue-50 border-blue-100' : 'bg-slate-50 border-transparent hover:border-slate-200'
|
||||
} ${!visibleModules[m] ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${i === 0 ? 'bg-blue-600 text-white' : 'bg-white text-slate-400'}`}>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-white text-slate-400">
|
||||
<Box size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -275,6 +489,50 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'mask' && (
|
||||
<div className="h-full grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-8">
|
||||
<div className="bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center overflow-hidden">
|
||||
<div className="relative w-80 h-80">
|
||||
{['#3b82f6', '#22c55e', '#f59e0b'].map((color, index) => (
|
||||
<div
|
||||
key={color}
|
||||
className="absolute inset-0 border-2"
|
||||
style={{
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}22`,
|
||||
borderRadius: index === 0 ? '48% 52% 46% 54%' : '58% 42% 52% 48%',
|
||||
transform: `rotate(${index * 36}deg) scale(${1 - index * 0.13})`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute left-5 top-5 text-white/50 font-mono text-[10px]">
|
||||
SEGMENTATION MASK PREVIEW · NII/NII.GZ
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-slate-50 rounded-2xl p-5 border border-slate-100">
|
||||
<h3 className="font-bold text-slate-800 mb-3">分割结果</h3>
|
||||
<p className="text-sm text-slate-500 leading-6">
|
||||
当前项目可导出 NIfTI 格式分割 mask。NII.GZ 为默认全量导出格式,适合后续医学影像工具链读取。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => downloadMask(selectedProject.id, 'nii.gz')}
|
||||
className="bg-slate-900 text-white px-5 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-black"
|
||||
>
|
||||
<Download size={18} /> 下载 NII.GZ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadMask(selectedProject.id, 'nii')}
|
||||
className="bg-white text-slate-700 px-5 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-50 border border-slate-200"
|
||||
>
|
||||
<Download size={18} /> 下载 NII
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user