2026-05-04-03-50-07 完善项目库可视化和项目管理

This commit is contained in:
2026-05-04 03:59:46 +08:00
parent a9b6d2d76a
commit 26d3109f63
11 changed files with 1010 additions and 88 deletions

View File

@@ -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 maskNII.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>
</>
) : (