2026-05-04-04-12-34 优化项目库导入和三维交互
This commit is contained in:
@@ -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 预览失败' });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface DicomPreview {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: string;
|
||||
plane: 'axial' | 'sagittal' | 'coronal';
|
||||
slice: number;
|
||||
total: number;
|
||||
fileName: string;
|
||||
|
||||
Reference in New Issue
Block a user