2026-05-04-04-12-34 优化项目库导入和三维交互

This commit is contained in:
2026-05-04 04:20:30 +08:00
parent 26d3109f63
commit 4aad0f815d
8 changed files with 632 additions and 105 deletions

View File

@@ -356,6 +356,46 @@ function parseDicomPreview(filePath: string) {
};
}
function parseDicomPixels(filePath: string) {
const preview = parseDicomPreview(filePath);
return {
...preview,
pixelBuffer: Buffer.from(preview.pixels, 'base64'),
};
}
function createReformattedPreview(files: string[], plane: 'sagittal' | 'coronal', slice: number) {
const first = parseDicomPixels(path.join(dicomDir, files[0]));
const maxSlice = plane === 'sagittal' ? first.width - 1 : first.height - 1;
const clampedSlice = Math.max(0, Math.min(maxSlice, slice));
const outputWidth = files.length;
const outputHeight = plane === 'sagittal' ? first.height : first.width;
const pixels = Buffer.alloc(outputWidth * outputHeight);
files.forEach((fileName, z) => {
const frame = parseDicomPixels(path.join(dicomDir, fileName));
for (let row = 0; row < outputHeight; row += 1) {
const sourceIndex = plane === 'sagittal'
? row * frame.width + clampedSlice
: clampedSlice * frame.width + row;
const targetIndex = row * outputWidth + z;
pixels[targetIndex] = frame.pixelBuffer[sourceIndex] ?? 0;
}
});
return {
width: outputWidth,
height: outputHeight,
pixels: pixels.toString('base64'),
windowCenter: first.windowCenter,
windowWidth: first.windowWidth,
slice: clampedSlice,
total: maxSlice + 1,
fileName: `${plane}-${clampedSlice}`,
};
}
async function startServer() {
const app = express();
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0';
@@ -445,6 +485,19 @@ async function startServer() {
res.json(project);
});
app.delete('/api/projects/:projectId', (req, res) => {
const state = readState();
const index = state.projects.findIndex((project) => project.id === req.params.projectId);
if (index < 0) {
res.status(404).json({ message: '项目不存在' });
return;
}
const [deleted] = state.projects.splice(index, 1);
writeState(state);
res.json({ ok: true, deletedId: deleted.id });
});
app.get('/api/projects/:projectId/dicom-preview', (req, res) => {
const project = findProject(readState(), req.params.projectId);
if (!project) {
@@ -458,15 +511,26 @@ async function startServer() {
return;
}
const requestedPlane = String(req.query.plane ?? 'axial');
const plane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial';
const requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10);
const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0));
try {
const preview = parseDicomPreview(path.join(dicomDir, files[slice]));
if (plane === 'axial') {
const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0));
const preview = parseDicomPreview(path.join(dicomDir, files[slice]));
res.json({
...preview,
plane,
slice,
total: files.length,
fileName: files[slice],
});
return;
}
res.json({
...preview,
slice,
total: files.length,
fileName: files[slice],
...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0),
plane,
});
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' });

View File

@@ -11,8 +11,9 @@ import {
FolderRoot,
Download,
Layers,
Save,
X
X,
Trash2,
Upload
} from 'lucide-react';
import { Canvas, useLoader } from '@react-three/fiber';
import { Bounds, Center, OrbitControls, Stage } from '@react-three/drei';
@@ -21,12 +22,29 @@ import * as THREE from 'three';
import { DicomPreview, Project } from '../types';
import { api, downloadMask } from '../lib/api';
function StlModel({ url }: { url: string }) {
type Plane = 'axial' | 'sagittal' | 'coronal';
interface ModuleStyle {
visible: boolean;
color: string;
opacity: number;
}
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
function StlModel({ url, color, opacity }: { url: string; color: string; opacity: number }) {
const geometry = useLoader(STLLoader, url);
return (
<mesh geometry={geometry as THREE.BufferGeometry}>
<meshStandardMaterial color="#3b82f6" roughness={0.48} metalness={0.08} side={THREE.DoubleSide} />
<meshStandardMaterial
color={color}
opacity={opacity}
transparent={opacity < 1}
roughness={0.48}
metalness={0.08}
side={THREE.DoubleSide}
/>
</mesh>
);
}
@@ -75,11 +93,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>('dicom');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [sliceIndex, setSliceIndex] = useState(0);
const [visibleModules, setVisibleModules] = useState<Record<string, boolean>>({});
const [plane, setPlane] = useState<Plane>('axial');
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [dicomError, setDicomError] = useState('');
const [selectedModelFile, setSelectedModelFile] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [editingProjectId, setEditingProjectId] = useState('');
const [editingName, setEditingName] = useState('');
const [actionMessage, setActionMessage] = useState('');
@@ -111,17 +131,24 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
return projects.filter((project) => project.name.toLowerCase().includes(keyword));
}, [projects, search]);
const subModules = selectedProject?.stlFiles?.length
? selectedProject.stlFiles.map((file) => file.replace(/\.stl$/i, ''))
: [];
const stlFiles = selectedProject?.stlFiles ?? [];
const planeOptions: Array<{ id: Plane; label: string }> = [
{ id: 'axial', label: '横断面' },
{ id: 'sagittal', label: '矢状面' },
{ id: 'coronal', label: '冠状面' },
];
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
useEffect(() => {
const next: Record<string, boolean> = {};
subModules.forEach((module) => {
next[module] = visibleModules[module] ?? true;
const next: Record<string, ModuleStyle> = {};
stlFiles.forEach((fileName, index) => {
next[fileName] = moduleStyles[fileName] ?? {
visible: true,
color: defaultModuleColors[index % defaultModuleColors.length],
opacity: 0.72,
};
});
setVisibleModules(next);
setSelectedModelFile(selectedProject?.stlFiles?.[0] ?? '');
setModuleStyles(next);
setSliceIndex(0);
}, [selectedProject?.id]);
@@ -133,7 +160,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
let cancelled = false;
setDicomError('');
api.getDicomPreview(selectedProject.id, sliceIndex)
api.getDicomPreview(selectedProject.id, sliceIndex, plane)
.then((preview) => {
if (!cancelled) {
setDicomPreview(preview);
@@ -149,17 +176,34 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
return () => {
cancelled = true;
};
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, viewMode]);
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, viewMode]);
const toggleModule = (name: string) => {
setVisibleModules(prev => ({ ...prev, [name]: !prev[name] }));
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
setModuleStyles(prev => ({
...prev,
[fileName]: {
visible: true,
color: '#3b82f6',
opacity: 0.72,
...(prev[fileName] ?? {}),
...partial,
},
}));
};
const toggleAllModules = () => {
const allVisible = Object.values(visibleModules).every(v => v);
const newState = { ...visibleModules };
subModules.forEach(m => newState[m] = !allVisible);
setVisibleModules(newState);
const nextVisible = !allModulesVisible;
setModuleStyles(prev => {
const next = { ...prev };
stlFiles.forEach((fileName, index) => {
next[fileName] = {
visible: nextVisible,
color: next[fileName]?.color ?? defaultModuleColors[index % defaultModuleColors.length],
opacity: next[fileName]?.opacity ?? 0.72,
};
});
return next;
});
};
const handleCreateProject = async () => {
@@ -170,6 +214,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
}
const created = await api.createProject(name);
setNewProjectName('');
setIsCreateModalOpen(false);
setActionMessage(`已创建项目:${created.name}`);
await refreshProjects();
setSelectedProject(created);
@@ -189,6 +234,28 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
setSelectedProject(updated);
};
const handleEditBlur = (project: Project) => {
if (editingProjectId !== project.id) {
return;
}
if (editingName.trim() && editingName.trim() !== project.name) {
handleRenameProject(project.id);
} else {
setEditingProjectId('');
setEditingName('');
}
};
const handleDeleteProject = async () => {
if (!projectToDelete) {
return;
}
await api.deleteProject(projectToDelete.id);
setActionMessage(`已删除项目:${projectToDelete.name}`);
setProjectToDelete(null);
await refreshProjects();
};
const tabs = [
{ id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon },
{ id: 'model' as const, label: '3D 模型', icon: Box },
@@ -215,33 +282,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<div className="flex items-center justify-between mb-4 px-1">
<h3 className="font-bold text-slate-800"></h3>
<button
onClick={handleCreateProject}
onClick={() => setIsCreateModalOpen(true)}
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
@@ -269,6 +316,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
value={editingName}
autoFocus
onClick={(event) => event.stopPropagation()}
onBlur={() => handleEditBlur(proj)}
onChange={(event) => setEditingName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') handleRenameProject(proj.id);
@@ -282,30 +330,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</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>
) : (
{editingProjectId !== proj.id && (
<div className="flex gap-2">
<button
onClick={(event) => {
event.stopPropagation();
@@ -317,6 +343,17 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
>
<Edit2 size={14} />
</button>
<button
onClick={(event) => {
event.stopPropagation();
setProjectToDelete(proj);
}}
className={selectedProject?.id === proj.id ? 'text-blue-100 hover:text-white' : 'text-slate-300 hover:text-rose-600'}
title="删除项目"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
<p className={`text-[10px] mt-1 ${selectedProject?.id === proj.id ? 'text-blue-100' : 'text-slate-400'}`}>
@@ -371,9 +408,11 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
>
<RotateCw size={18} />
</button>
<button className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all">
<Download size={18} />
</button>
{viewMode !== 'mask' && (
<button className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all">
<Upload size={18} />
</button>
)}
</div>
</div>
@@ -382,6 +421,22 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<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">
<div className="absolute top-4 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
{planeOptions.map((option) => (
<button
key={option.id}
onClick={() => {
setPlane(option.id);
setSliceIndex(0);
}}
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
plane === option.id ? 'bg-blue-600 text-white' : 'text-white/50 hover:text-white'
}`}
>
{option.label}
</button>
))}
</div>
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
<p>SCAN DATE: {selectedProject.createTime}</p>
@@ -396,19 +451,25 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40}</span>
<span>SLICE: {sliceIndex}/{selectedProject.dicomCount}</span>
<span> {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} </span>
</div>
</div>
{/* Right: Vertical Progress Bar */}
<div className="w-16 h-full flex flex-col items-center py-2 bg-slate-50 rounded-2xl">
<span className="text-[10px] text-slate-400 font-bold mb-4">NAV</span>
<div className="w-24 h-full flex flex-col items-center py-4 bg-slate-50 rounded-2xl">
<span className="text-[10px] text-slate-400 font-bold mb-3"></span>
<span className="text-[10px] text-slate-500 font-bold mb-4 whitespace-nowrap">
{sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount}
</span>
<input
type="range" min="0" max={Math.max(selectedProject.dicomCount, 1)} value={sliceIndex}
type="range"
min="0"
max={Math.max((dicomPreview?.total ?? selectedProject.dicomCount) - 1, 0)}
value={sliceIndex}
onChange={(e) => setSliceIndex(Number(e.target.value))}
className="flex-1 w-1.5 appearance-none bg-slate-200 rounded-full focus:outline-none accent-blue-600 cursor-pointer"
style={{ writingMode: 'bt-lr' as any }}
className="flex-1 w-6 accent-blue-600 cursor-pointer"
style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
/>
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex}</span>
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex + 1}</span>
</div>
</div>
)}
@@ -422,10 +483,25 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<ambientLight intensity={0.65} />
<directionalLight position={[3, 6, 5]} intensity={1.1} />
<Suspense fallback={null}>
{selectedModelFile ? (
{stlFiles.some((fileName) => moduleStyles[fileName]?.visible !== false) ? (
<Bounds fit clip observe margin={1.25}>
<Center>
<StlModel url={`/api/projects/${selectedProject.id}/models/${encodeURIComponent(selectedModelFile)}`} />
<group>
{stlFiles.map((fileName) => {
const style = moduleStyles[fileName] ?? { visible: true, color: '#3b82f6', opacity: 0.72 };
if (!style.visible) {
return null;
}
return (
<StlModel
key={fileName}
url={`/api/projects/${selectedProject.id}/models/${encodeURIComponent(fileName)}`}
color={style.color}
opacity={style.opacity}
/>
);
})}
</group>
</Center>
</Bounds>
) : (
@@ -446,45 +522,61 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{/* Right: Sub-module List */}
<div className="w-64 h-full flex flex-col overflow-hidden">
<div className="px-1 flex items-center justify-between mb-3 shrink-0">
<p className="text-xs font-bold text-slate-700 uppercase tracking-widest"> ({subModules.length})</p>
<p className="text-xs font-bold text-slate-700 uppercase tracking-widest"> ({stlFiles.length})</p>
<button
onClick={toggleAllModules}
className={`p-1 rounded hover:bg-slate-100 transition-colors ${Object.values(visibleModules).every(v => v) ? 'text-blue-500' : 'text-slate-400'}`}
title={Object.values(visibleModules).every(v => v) ? "全隐藏" : "全显示"}
className={`p-1 rounded hover:bg-slate-100 transition-colors ${allModulesVisible ? 'text-blue-500' : 'text-slate-400'}`}
title={allModulesVisible ? "全隐藏" : "全显示"}
>
<Eye size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
{subModules.map((m, i) => (
{stlFiles.map((fileName, i) => {
const name = fileName.replace(/\.stl$/i, '');
const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72 };
return (
<div
key={m}
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' : ''}`}
key={fileName}
className={`p-3 rounded-xl border flex items-start gap-3 group transition-all bg-slate-50 border-transparent hover:border-slate-200 ${!style.visible ? 'opacity-50' : ''}`}
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-white text-slate-400">
<Box size={14} />
</div>
<input
type="color"
value={style.color}
onChange={(event) => updateModuleStyle(fileName, { color: event.target.value })}
className="w-8 h-8 rounded-lg border border-white bg-white p-0.5 cursor-pointer shrink-0"
title="模型颜色"
/>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-bold text-slate-700 truncate">{m}</p>
<p className="text-[9px] text-slate-400">STL | {selectedProject.stlFiles?.[i]}</p>
<p className="text-[11px] font-bold text-slate-700 truncate">{name}</p>
<p className="text-[9px] text-slate-400 truncate">STL | {fileName}</p>
<div className="mt-2 flex items-center gap-2">
<span className="text-[9px] text-slate-400 shrink-0"></span>
<input
type="range"
min="0.1"
max="1"
step="0.05"
value={style.opacity}
onChange={(event) => updateModuleStyle(fileName, { opacity: Number(event.target.value) })}
className="min-w-0 flex-1 accent-blue-600"
/>
<span className="text-[9px] text-slate-400 w-7 text-right">{Math.round(style.opacity * 100)}%</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className={`w-1.5 h-1.5 rounded-full ${i === 4 ? 'bg-amber-400' : 'bg-emerald-500'}`} />
<button
onClick={(e) => {
e.stopPropagation();
toggleModule(m);
updateModuleStyle(fileName, { visible: !style.visible });
}}
className={`p-1 rounded hover:bg-white transition-colors ${visibleModules[m] ? 'text-blue-500 underline decoration-2' : 'text-slate-300'}`}
className={`p-1 rounded hover:bg-white transition-colors ${style.visible ? 'text-blue-500 underline decoration-2' : 'text-slate-300'}`}
>
<Eye size={14} />
</button>
</div>
</div>
))}
)})}
</div>
</div>
</div>
@@ -544,6 +636,74 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</div>
)}
</div>
{isCreateModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl border border-slate-100">
<div className="flex items-center justify-between mb-5">
<h3 className="font-bold text-slate-900"></h3>
<button
onClick={() => setIsCreateModalOpen(false)}
className="text-slate-400 hover:text-slate-700"
title="关闭"
>
<X size={18} />
</button>
</div>
<input
value={newProjectName}
autoFocus
onChange={(event) => setNewProjectName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleCreateProject();
}
}}
placeholder="请输入项目名称"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setIsCreateModalOpen(false)}
className="px-4 py-2 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-100"
>
</button>
<button
onClick={handleCreateProject}
className="px-4 py-2 rounded-xl text-sm font-bold bg-blue-600 text-white hover:bg-blue-700"
>
</button>
</div>
</div>
</div>
)}
{projectToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl border border-slate-100">
<h3 className="font-bold text-slate-900 mb-2"></h3>
<p className="text-sm text-slate-500 leading-6">
{projectToDelete.name}使
</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setProjectToDelete(null)}
className="px-4 py-2 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-100"
>
</button>
<button
onClick={handleDeleteProject}
className="px-4 py-2 rounded-xl text-sm font-bold bg-rose-600 text-white hover:bg-rose-700"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -46,8 +46,12 @@ export const api = {
method: 'PATCH',
body: JSON.stringify({ name }),
}),
getDicomPreview: (projectId: string, slice: number) =>
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}`),
deleteProject: (projectId: string) =>
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
method: 'DELETE',
}),
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial') =>
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}`),
getUsers: () => request<UserRecord[]>('/api/users'),
resetDemo: () =>
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {

View File

@@ -59,6 +59,7 @@ export interface DicomPreview {
width: number;
height: number;
pixels: string;
plane: 'axial' | 'sagittal' | 'coronal';
slice: number;
total: number;
fileName: string;

View File

@@ -0,0 +1,77 @@
# 实现方案
时间戳2026-05-04-04-12-34
## 修改目标
完善项目库导入/下载按钮语义、DICOM 三方向预览、STL 多模型颜色透明度控制、项目创建弹窗、删除确认和编辑自动保存。
## 涉及路径
- `WebSite/server.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/src/components/ProjectLibrary.tsx`
- `工程分析/测试方案-2026-05-04-04-12-34.md`
- `工程分析/经验记录.md`
## 技术路线
1. 后端 DICOM 预览扩展。
- `GET /api/projects/:projectId/dicom-preview?slice=&plane=`
- 支持 `plane=axial|sagittal|coronal`
- 横断面读取单张 DICOM。
- 矢状面/冠状面从 DICOM 序列采样生成重建平面。
2. 后端项目删除。
- 新增 `DELETE /api/projects/:projectId`
- 删除后写入共享状态。
3. 前端 API 扩展。
- `getDicomPreview(projectId, slice, plane)`
- `deleteProject(projectId)`
4. 项目库交互。
- 顶部右侧按钮DICOM/3D 视图显示“导入”;分割结果不显示顶部第二按钮。
- 创建项目改为点击 `+` 弹窗输入名称。
- 编辑项目名称改为输入框失焦或回车自动保存。
- 删除项目点击垃圾桶后弹窗二次确认。
5. DICOM UI。
- 增加横断面、矢状面、冠状面切换。
- 右侧滑块改为稳定轨道,显示 `第 n / 总数`
- 圆点与轨道对齐。
6. 3D 模型 UI。
- 右侧眼睛为全体显示/隐藏。
- 每个 STL 使用颜色输入框和透明度滑块。
- Three.js 同时渲染所有可见 STL并应用对应颜色和透明度。
- 删除无意义状态点。
## 数据流
DICOM
前端选择方向和切片 -> 后端按方向返回灰度像素 -> 前端 canvas 绘制。
3D
后端提供 STL 文件 -> 前端为每个 STL 建立颜色/透明度/可见性状态 -> Three.js 渲染多模型。
项目:
创建弹窗 -> `POST /api/projects`;编辑失焦 -> `PATCH /api/projects/:id`;删除确认 -> `DELETE /api/projects/:id`
## 兼容性与回滚方案
- 保留原 `axial` 行为,新增方向参数不影响旧调用。
- 若矢状面/冠状面解析失败,前端显示错误态。
- 若 STL 多模型性能不足,可通过全体眼睛或单项眼睛隐藏模型。
- 回滚时恢复 `ProjectLibrary.tsx` 和相关 API 即可。
## 预计文件变更
- 修改 `server.ts``api.ts``types.ts``ProjectLibrary.tsx`
- 更新测试方案执行结果。
- 更新经验记录。
## 人工审核状态
本次用户明确要求无需人工二次确认。
状态:自动确认,继续执行。

View File

@@ -0,0 +1,108 @@
# 测试方案
时间戳2026-05-04-04-12-34
## 测试目标
验证项目库导入按钮语义、DICOM 三方向切片、STL 颜色透明度控制、项目创建弹窗、编辑自动保存和删除确认能力。
## 静态检查
- 检查 `ProjectLibrary.tsx`
- DICOM/3D 顶部第二按钮为“导入”。
- 分割结果页无顶部第二按钮。
- 创建项目通过弹窗触发。
- 删除项目通过确认弹窗触发。
- 编辑项目名称无保存按钮,失焦自动保存。
- STL 模块包含颜色输入和透明度滑块。
- 删除无意义状态点。
- 检查 `server.ts`
- DICOM preview 支持 `plane`
- 项目支持 `DELETE`
## 构建与类型检查
```bash
cd WebSite
npm run lint
npm run build
```
预期:
- TypeScript 检查通过。
- Vite 构建通过。
## API 验证
```bash
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0'
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=256'
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=256'
curl -s -X POST http://127.0.0.1:4000/api/projects -H 'Content-Type: application/json' -d '{"name":"删除测试项目"}'
curl -s -X DELETE http://127.0.0.1:4000/api/projects/<id>'
```
预期:
- 三方向 DICOM preview 均返回 `width``height``pixels``plane`
- 创建项目成功。
- 删除项目成功。
## 页面验证
- DICOM 页右侧控制条圆点与轨道对齐。
- DICOM 页显示 `第 n / 总数`
- 可切换横断面、矢状面、冠状面。
- 3D 页整体眼睛可控制所有 STL 显示/隐藏。
- 单个 STL 的颜色和透明度控制影响模型渲染。
- 项目创建由弹窗完成。
- 项目编辑失焦自动保存。
- 项目删除需要二次确认。
## 回归风险
- 矢状面/冠状面每次请求会读取多张 DICOM可能有延迟。
- 多 STL 同时显示时首屏加载可能较慢。
## 人工审核状态
本次用户明确要求无需人工二次确认。
状态:自动确认,继续执行。
## 执行结果
- `npm run lint` 执行成功。
- `npm run build` 执行成功。
- Vite 仍有大 chunk 警告,当前不影响本次功能。
- `GET /api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0` 返回:
- `plane: axial`
- `width: 512`
- `height: 512`
- `total: 300`
- `GET /api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=0` 返回:
- `plane: sagittal`
- `width: 300`
- `height: 512`
- `total: 512`
- `GET /api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=0` 返回:
- `plane: coronal`
- `width: 300`
- `height: 512`
- `total: 512`
- `POST /api/projects` 创建删除测试项目成功。
- `DELETE /api/projects/:id` 删除测试项目成功。
- `POST /api/demo/reset` 执行成功,演示环境已恢复默认项目。
- headless Chrome 打开页面后未捕获 Recharts 宽高警告、`Uncaught` 或页面错误。
- `http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`
- 当前服务由 `tmux` 会话 `revoxelseg-dicom` 托管。
## 页面侧验证点
- 顶部第二按钮在 `DICOM 影像``3D 模型` 视图显示为“导入”。
- `分割结果` 视图顶部不显示额外导出按钮,只保留下方 NII/NII.GZ 下载按钮。
- 项目创建入口改为点击 `+` 后弹窗。
- 项目删除通过确认弹窗执行。
- 项目编辑输入框失焦或回车自动保存。
- 3D 模型侧栏每个 STL 提供颜色和透明度控制,整体眼睛控制所有 STL 可见性。

View File

@@ -271,3 +271,57 @@ C. 解决问题方案
D. 后续如何避免问题
只有当需要真实 DICOM 空间解析、STL 体素填充、NIfTI 精确写入或批处理算法时,再引入 Python/conda并把环境文件纳入项目文档。
## 2026-05-04-04-12-34 DICOM 三方向预览
A. 具体问题
项目库 DICOM 影像只能看横断面,且右侧切片控制显示为 `NAV``#0`,不符合医学影像浏览习惯。
B. 产生问题原因
旧前端只把 DICOM 序列当成单一轴向切片数组浏览,后端 DICOM preview API 只返回单张横断面。
C. 解决问题方案
后端 DICOM preview API 增加 `plane=axial|sagittal|coronal` 参数。横断面读取单张 DICOM矢状面和冠状面从 DICOM 序列逐张采样重建灰度平面。前端增加方向切换,并把右侧控制改为 `第 n / 总数` 的切片语义。
D. 后续如何避免问题
医学影像浏览控件应按方向、当前切片和总切片数表达,不使用模糊的导航标签;多方向重建后续可加入缓存或 Python 预处理优化性能。
## 2026-05-04-04-12-34 STL 多模型样式控制
A. 具体问题
3D 模型视图只显示单个 STL右侧状态点无实际意义用户需要不同 STL 有独立颜色和透明度,并由整体眼睛统一控制显示。
B. 产生问题原因
旧实现把 STL 列表当成选择器,只加载当前选中的一个模型;侧栏状态点只是装饰,没有绑定模型材质。
C. 解决问题方案
前端为每个 STL 建立 `visible``color``opacity` 状态,同时加载所有可见 STL每个模型材质绑定对应颜色和透明度右侧整体眼睛统一切换所有 STL 可见性,删除无意义状态点。
D. 后续如何避免问题
三维列表中的视觉控件必须和真实渲染状态绑定;颜色、透明度、可见性等控件不应只是静态装饰。
## 2026-05-04-04-12-34 项目管理交互
A. 具体问题
创建项目输入框常驻在项目列表中占空间;项目编辑需要手动保存按钮;项目缺少删除入口和二次确认。
B. 产生问题原因
项目管理功能初版偏向快速可用,没有区分高频浏览和低频管理操作。
C. 解决问题方案
创建项目改为点击 `+` 后弹窗输入;项目名编辑改为失焦或回车自动保存;项目右侧增加删除按钮,点击后必须在确认弹窗中再次确认。
D. 后续如何避免问题
列表页面应减少常驻表单噪声;破坏性操作必须二次确认;轻量编辑可采用失焦保存,但需要避免空名称提交。

View File

@@ -0,0 +1,59 @@
# 需求分析
时间戳2026-05-04-04-12-34
## 原始需求摘要
用户要求严格使用代码编纂工作流处理本次修改,并在开始时确认工作流整体流程;本次需求分析、实现方案、测试方案和执行修改均不需要人工二次确认。
具体需求:
1. 项目库中 `DICOM 影像``3D 模型` 视图右侧按钮应为“导入”,不是“导出”;`分割结果` 视图右侧不需要顶部导出按钮,因为下方已有下载按钮。
2. DICOM 影像右侧滚动条展示差,圆圈不在条上;切片进度不应显示为 `0~NAV`,应显示当前第几张/总张数;除横断面外,增加矢状面、冠状面选择。
3. 3D 模型右侧眼睛表示整体显示开关;不同 STL 前面应为 RGB 颜色框,可调整颜色与透明度,并在模型显示中生效;删除无意义状态点样式。
4. 项目列表中已有项目右侧除编辑外增加删除按钮,删除需要二次确认。
5. 创建项目交互改为点击 `+` 后弹窗创建,删除常驻的“新增项目名称”输入栏。
6. 项目名称编辑后不需要保存按钮,点击其他区域自动保存。
## 业务目标
- 优化项目库的资产管理交互,使导入、下载、创建、编辑、删除的语义明确。
- 改善 DICOM 浏览体验,支持横断面、矢状面、冠状面三方向预览。
- 改善 STL 多模型浏览体验,支持每个 STL 独立颜色和透明度,并提供整体显示开关。
- 降低项目列表的视觉噪声,创建项目采用弹窗,编辑项目采用自动保存。
## 输入与输出
输入:
- 用户在项目库中选择 DICOM 方向与切片。
- 用户调整 STL 模块颜色、透明度、可见性。
- 用户创建、编辑、删除项目。
输出:
- DICOM 预览支持 `axial``sagittal``coronal`
- 右侧切片控制显示为 `第 n / 总数`
- 3D 模型视图同时显示多个 STL并应用颜色/透明度。
- 项目创建弹窗。
- 项目删除确认弹窗。
- 项目名编辑失焦自动保存。
## 影响范围
- `WebSite/server.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/src/components/ProjectLibrary.tsx`
- `工程分析/经验记录.md`
## 风险点
- 矢状面/冠状面预览需要从多个 DICOM 切片采样,性能比横断面低。本次以演示可用为主,后续可加入缓存或 Python 预处理。
- 同时加载 9 个 STL 可能增加浏览器渲染压力,需要保持透明度和可见性状态可控。
- 自动保存项目名需要避免空名称提交。
- 删除项目需要防止误删默认项目或至少提供明确二次确认。本次默认项目也允许删除前确认,但恢复出厂设置可恢复默认项目。
## 待确认问题
- 本次用户已明确无需二次确认,直接执行。