Files
REVOXELSEG_DICOM/WebSite/src/components/ProjectLibrary.tsx

1551 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Plus,
Search,
Eye,
FileArchive,
RotateCw,
RotateCcw,
Box,
Image as ImageIcon,
Info,
ChevronRight,
ChevronUp,
ChevronDown,
Edit2,
FolderRoot,
Download,
Layers,
X,
Trash2,
Upload
} from 'lucide-react';
import * as THREE from 'three';
import { DicomInfo, DicomPreview, Project } from '../types';
import { api, downloadDicomArchive, downloadMask } from '../lib/api';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
type SolidityLevel = 'preview' | 'standard' | 'fine' | 'ultra';
interface ModuleStyle {
visible: boolean;
color: string;
opacity: number;
}
interface ModelPose {
rotateX: number;
rotateY: number;
rotateZ: number;
translateX: number;
translateY: number;
translateZ: number;
scale: number;
}
interface ModelPreviewPayload {
fileName: string;
triangleCount: number;
sampledTriangles: number;
vertices: number[];
}
type ModelPoseKey = keyof ModelPose;
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
{ id: 'preview', label: '预览', limit: 6000 },
{ id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 },
{ id: 'ultra', label: '超精细', limit: 72000 },
];
const defaultModelPose: ModelPose = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
};
const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
rotateX: { min: -180, max: 180 },
rotateY: { min: -180, max: 180 },
rotateZ: { min: -180, max: 180 },
translateX: { min: -2, max: 2 },
translateY: { min: -2, max: 2 },
translateZ: { min: -2, max: 2 },
scale: { min: 0.5, max: 2.5 },
};
function clampModelPoseValue(key: ModelPoseKey, value: number) {
const limit = modelPoseLimits[key];
return Math.max(limit.min, Math.min(limit.max, value));
}
function clampModelPose(next: ModelPose): ModelPose {
return {
rotateX: clampModelPoseValue('rotateX', next.rotateX),
rotateY: clampModelPoseValue('rotateY', next.rotateY),
rotateZ: clampModelPoseValue('rotateZ', next.rotateZ),
translateX: clampModelPoseValue('translateX', next.translateX),
translateY: clampModelPoseValue('translateY', next.translateY),
translateZ: clampModelPoseValue('translateZ', next.translateZ),
scale: clampModelPoseValue('scale', next.scale),
};
}
function drawFallbackModelPreview(
canvas: HTMLCanvasElement,
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
) {
const rect = canvas.getBoundingClientRect();
const parentRect = canvas.parentElement?.getBoundingClientRect();
const width = Math.max(Math.floor(rect.width || parentRect?.width || 720), 1);
const height = Math.max(Math.floor(rect.height || parentRect?.height || 460), 1);
canvas.width = width * window.devicePixelRatio;
canvas.height = height * window.devicePixelRatio;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const context = canvas.getContext('2d');
if (!context) return;
context.scale(window.devicePixelRatio, window.devicePixelRatio);
context.fillStyle = '#f8fafc';
context.fillRect(0, 0, width, height);
const allPoints = previews.flatMap(({ payload }) => {
const points: Array<[number, number]> = [];
for (let index = 0; index < payload.vertices.length; index += 3) {
points.push([payload.vertices[index], payload.vertices[index + 1]]);
}
return points;
});
if (!allPoints.length) return;
const xs = allPoints.map((point) => point[0]);
const ys = allPoints.map((point) => point[1]);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const spanX = Math.max(maxX - minX, 1);
const spanY = Math.max(maxY - minY, 1);
const scale = Math.min((width * 0.78) / spanX, (height * 0.78) / spanY);
const offsetX = width / 2 - ((minX + maxX) / 2) * scale;
const offsetY = height / 2 + ((minY + maxY) / 2) * scale;
previews.forEach(({ payload, style }) => {
context.globalAlpha = Math.max(0.12, Math.min(style.opacity, 1));
context.fillStyle = style.color;
context.strokeStyle = style.color;
for (let index = 0; index < payload.vertices.length; index += 9) {
const x1 = payload.vertices[index] * scale + offsetX;
const y1 = -payload.vertices[index + 1] * scale + offsetY;
const x2 = payload.vertices[index + 3] * scale + offsetX;
const y2 = -payload.vertices[index + 4] * scale + offsetY;
const x3 = payload.vertices[index + 6] * scale + offsetX;
const y3 = -payload.vertices[index + 7] * scale + offsetY;
context.beginPath();
context.moveTo(x1, y1);
context.lineTo(x2, y2);
context.lineTo(x3, y3);
context.closePath();
context.fill();
context.stroke();
}
});
context.globalAlpha = 1;
}
function drawDicomPreviewToCanvas(canvas: HTMLCanvasElement, preview: DicomPreview, rotation: number) {
const normalizedRotation = ((rotation % 360) + 360) % 360;
const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = preview.width;
sourceCanvas.height = preview.height;
const sourceContext = sourceCanvas.getContext('2d');
const targetContext = canvas.getContext('2d');
if (!sourceContext || !targetContext) {
return;
}
const binary = atob(preview.pixels);
const imageData = sourceContext.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;
}
sourceContext.putImageData(imageData, 0, 0);
const isQuarterTurn = normalizedRotation === 90 || normalizedRotation === 270;
canvas.width = isQuarterTurn ? preview.height : preview.width;
canvas.height = isQuarterTurn ? preview.width : preview.height;
targetContext.clearRect(0, 0, canvas.width, canvas.height);
targetContext.save();
targetContext.imageSmoothingEnabled = true;
if (normalizedRotation === 90) {
targetContext.translate(canvas.width, 0);
targetContext.rotate(Math.PI / 2);
} else if (normalizedRotation === 180) {
targetContext.translate(canvas.width, canvas.height);
targetContext.rotate(Math.PI);
} else if (normalizedRotation === 270) {
targetContext.translate(0, canvas.height);
targetContext.rotate(-Math.PI / 2);
}
targetContext.drawImage(sourceCanvas, 0, 0);
targetContext.restore();
}
function safeFilePart(value: string) {
return value.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'dicom';
}
function displayDicomValue(value: string | number | null | undefined) {
if (value === null || value === undefined || value === '') {
return '未知';
}
return String(value);
}
function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
drawDicomPreviewToCanvas(canvas, preview, rotation);
}, [preview, rotation]);
return (
<canvas
ref={canvasRef}
className="max-h-full max-w-full object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
/>
);
}
function NativeStlViewer({
projectId,
files,
styles,
detailLimit,
pose,
onPoseChange,
}: {
projectId: string;
files: string[];
styles: Record<string, ModuleStyle>;
detailLimit: number;
pose: ModelPose;
onPoseChange: React.Dispatch<React.SetStateAction<ModelPose>>;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const poseRef = useRef<ModelPose>(pose);
const onPoseChangeRef = useRef(onPoseChange);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('准备加载模型');
useEffect(() => {
poseRef.current = pose;
}, [pose]);
useEffect(() => {
onPoseChangeRef.current = onPoseChange;
}, [onPoseChange]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const dragState = {
active: false,
mode: 'rotate' as 'rotate' | 'pan',
pointerId: 0,
startX: 0,
startY: 0,
startPose: poseRef.current,
};
const handlePointerDown = (event: PointerEvent) => {
dragState.active = true;
dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
dragState.pointerId = event.pointerId;
dragState.startX = event.clientX;
dragState.startY = event.clientY;
dragState.startPose = poseRef.current;
container.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent) => {
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (dragState.mode === 'pan') {
onPoseChangeRef.current(clampModelPose({
...dragState.startPose,
translateX: dragState.startPose.translateX + deltaX * 0.006,
translateY: dragState.startPose.translateY - deltaY * 0.006,
}));
return;
}
onPoseChangeRef.current(clampModelPose({
...dragState.startPose,
rotateY: dragState.startPose.rotateY + deltaX * 0.35,
rotateX: dragState.startPose.rotateX + deltaY * 0.35,
}));
};
const stopPointerDrag = (event: PointerEvent) => {
if (event.pointerId !== dragState.pointerId) return;
dragState.active = false;
if (container.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId);
}
};
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
onPoseChangeRef.current(clampModelPose({
...poseRef.current,
scale: poseRef.current.scale - event.deltaY * 0.001,
}));
};
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
container.addEventListener('pointerdown', handlePointerDown);
container.addEventListener('pointermove', handlePointerMove);
container.addEventListener('pointerup', stopPointerDrag);
container.addEventListener('pointercancel', stopPointerDrag);
container.addEventListener('wheel', handleWheel, { passive: false });
container.addEventListener('contextmenu', preventContextMenu);
return () => {
container.removeEventListener('pointerdown', handlePointerDown);
container.removeEventListener('pointermove', handlePointerMove);
container.removeEventListener('pointerup', stopPointerDrag);
container.removeEventListener('pointercancel', stopPointerDrag);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('contextmenu', preventContextMenu);
};
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const visibleFiles = files.filter((file) => styles[file]?.visible !== false);
container.innerHTML = '';
setProgress(visibleFiles.length ? 5 : 0);
setStatus(visibleFiles.length ? '正在加载 STL 模型...' : '没有可显示的模型');
if (!visibleFiles.length) {
return;
}
let disposed = false;
let animationId = 0;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#f8fafc');
const camera = new THREE.PerspectiveCamera(45, Math.max(container.clientWidth, 1) / Math.max(container.clientHeight, 1), 0.1, 1000);
camera.up.set(0, 1, 0);
camera.position.set(0, 0, 6);
camera.lookAt(0, 0, 0);
let renderer: THREE.WebGLRenderer | null = null;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
} catch {
const fallbackCanvas = document.createElement('canvas');
fallbackCanvas.className = 'absolute inset-0 h-full w-full';
container.appendChild(fallbackCanvas);
setStatus('WebGL 不可用,正在生成二维模型预览...');
let fallbackPreviews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }> = [];
Promise.allSettled(
visibleFiles.map((fileName) =>
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=3500`)
.then((response) => {
if (!response.ok) throw new Error('模型预览数据加载失败');
return response.json() as Promise<ModelPreviewPayload>;
})
.then((payload) => ({
payload,
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true },
})),
),
).then((results) => {
if (disposed) return;
const previews = results
.filter((result): result is PromiseFulfilledResult<{ payload: ModelPreviewPayload; style: ModuleStyle }> => result.status === 'fulfilled')
.map((result) => result.value);
fallbackPreviews = previews;
drawFallbackModelPreview(fallbackCanvas, previews);
setProgress(100);
setStatus(previews.length ? '二维模型预览已生成' : '模型预览加载失败');
});
const handleFallbackResize = () => {
if (fallbackPreviews.length) {
drawFallbackModelPreview(fallbackCanvas, fallbackPreviews);
}
};
window.addEventListener('resize', handleFallbackResize);
return () => {
disposed = true;
window.removeEventListener('resize', handleFallbackResize);
container.innerHTML = '';
};
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.72));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
keyLight.position.set(4, 5, 6);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x9cc4ff, 0.55);
fillLight.position.set(-4, 2, -3);
scene.add(fillLight);
const poseGroup = new THREE.Group();
const pivotGroup = new THREE.Group();
poseGroup.add(pivotGroup);
let baseScale = 1;
scene.add(poseGroup);
let loaded = 0;
let failed = 0;
visibleFiles.forEach((fileName) => {
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
.then((response) => {
if (!response.ok) {
throw new Error('模型预览数据加载失败');
}
return response.json() as Promise<ModelPreviewPayload>;
})
.then((payload) => {
if (disposed) return;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
geometry.computeVertexNormals();
const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true };
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color: style.color,
opacity: style.opacity,
transparent: style.opacity < 1,
roughness: 0.42,
metalness: 0.04,
side: THREE.DoubleSide,
}),
);
pivotGroup.add(mesh);
loaded += 1;
setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100));
setStatus(`已加载 ${loaded} / ${visibleFiles.length} 个 STL 预览`);
if (loaded + failed === visibleFiles.length) {
const box = new THREE.Box3().setFromObject(pivotGroup);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxSize = Math.max(size.x, size.y, size.z) || 1;
pivotGroup.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.translate(-center.x, -center.y, -center.z);
object.geometry.computeBoundingSphere();
object.geometry.computeVertexNormals();
}
});
poseGroup.position.set(0, 0, 0);
pivotGroup.position.set(0, 0, 0);
baseScale = 4.2 / maxSize;
pivotGroup.scale.setScalar(baseScale * poseRef.current.scale);
camera.lookAt(0, 0, 0);
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
}
})
.catch(() => {
if (disposed) return;
failed += 1;
setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100));
setStatus(`${failed} 个模型加载失败`);
});
});
const handleResize = () => {
if (!container.clientWidth || !container.clientHeight) return;
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
};
window.addEventListener('resize', handleResize);
const animate = () => {
if (disposed) return;
const currentPose = poseRef.current;
poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
pivotGroup.rotation.set(
THREE.MathUtils.degToRad(currentPose.rotateX),
THREE.MathUtils.degToRad(currentPose.rotateY),
THREE.MathUtils.degToRad(currentPose.rotateZ),
);
pivotGroup.scale.setScalar(baseScale * currentPose.scale);
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
animate();
return () => {
disposed = true;
window.cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
renderer.dispose();
poseGroup.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.dispose();
const material = object.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material.dispose();
}
}
});
container.innerHTML = '';
};
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit]);
return (
<div className="h-full w-full relative cursor-grab active:cursor-grabbing">
<div ref={containerRef} className="absolute inset-0" />
{progress < 100 && (
<div className="absolute inset-x-8 top-8 z-10 rounded-xl bg-white/90 p-4 shadow-sm border border-slate-100">
<div className="flex items-center justify-between text-xs font-bold text-slate-600 mb-2">
<span>{status}</span>
<span>{progress}%</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-blue-600 transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{progress >= 100 && (
<div className="absolute left-4 top-4 rounded-lg bg-white/80 px-3 py-1.5 text-[10px] font-bold text-slate-500 shadow-sm">
{status}
</div>
)}
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-slate-200 bg-white/85 px-3 py-2 shadow-sm">
<div className="mb-2 flex h-12 w-16 items-end justify-center">
<div className="relative h-10 w-10">
<span className="absolute left-5 top-5 h-px w-8 origin-left -rotate-[18deg] bg-red-500" />
<span className="absolute left-[19px] top-5 h-8 w-px origin-bottom bg-emerald-500" />
<span className="absolute left-5 top-5 h-px w-7 origin-left rotate-[42deg] bg-blue-500" />
<span className="absolute -right-3 top-3 text-[9px] font-black text-red-500">X</span>
<span className="absolute left-4 -top-2 text-[9px] font-black text-emerald-500">Y</span>
<span className="absolute right-0 bottom-0 text-[9px] font-black text-blue-500">Z</span>
</div>
</div>
<div className="space-y-0.5 font-mono text-[9px] text-slate-500">
<div>X {Math.round(pose.rotateX)}°</div>
<div>Y {Math.round(pose.rotateY)}°</div>
<div>Z {Math.round(pose.rotateZ)}°</div>
</div>
</div>
</div>
);
}
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' | 'mask'>('dicom');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [sliceIndex, setSliceIndex] = useState(0);
const [plane, setPlane] = useState<Plane>('axial');
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
const [rotation, setRotation] = useState(0);
const [isSliceChanging, setIsSliceChanging] = useState(false);
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(null);
const [dicomInfoError, setDicomInfoError] = useState('');
const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false);
const [dicomError, setDicomError] = 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('');
const sliceRepeatRef = useRef<number | null>(null);
const dicomRequestRef = useRef(0);
const refreshProjects = () => {
setLoading(true);
return api.getProjects()
.then((items) => {
setProjects(items);
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(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return projects;
}
return projects.filter((project) => project.name.toLowerCase().includes(keyword));
}, [projects, search]);
const stlFiles = selectedProject?.stlFiles ?? [];
const planeOptions: Array<{ id: Plane; label: string }> = [
{ id: 'axial', label: '横断面' },
{ id: 'sagittal', label: '矢状面' },
{ id: 'coronal', label: '冠状面' },
];
const displayModes: Array<{ id: DisplayMode; label: string }> = [
{ id: 'default', label: '默认' },
{ id: 'bone', label: '骨窗' },
{ id: 'soft', label: '软组织' },
{ id: 'contrast', label: '高对比' },
];
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[1];
useEffect(() => {
const next: Record<string, ModuleStyle> = {};
stlFiles.forEach((fileName, index) => {
next[fileName] = moduleStyles[fileName] ?? {
visible: true,
color: defaultModuleColors[index % defaultModuleColors.length],
opacity: 0.72,
};
});
setModuleStyles(next);
setSliceIndex(0);
setModelPose(defaultModelPose);
}, [selectedProject?.id]);
useEffect(() => {
if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) {
setDicomPreview(null);
setIsSliceChanging(false);
return;
}
let cancelled = false;
const requestId = dicomRequestRef.current + 1;
dicomRequestRef.current = requestId;
setDicomError('');
setIsSliceChanging(true);
api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
.then((preview) => {
if (!cancelled && requestId === dicomRequestRef.current) {
setDicomPreview(preview);
setIsSliceChanging(false);
}
})
.catch((error) => {
if (!cancelled && requestId === dicomRequestRef.current) {
setDicomPreview(null);
setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败');
setIsSliceChanging(false);
}
});
return () => {
cancelled = true;
};
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]);
useEffect(() => () => {
if (sliceRepeatRef.current !== null) {
window.clearInterval(sliceRepeatRef.current);
}
}, []);
useEffect(() => {
const max = Math.max(sliceTotal - 1, 0);
if (sliceIndex > max) {
setSliceIndex(max);
}
}, [sliceIndex, sliceTotal]);
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
setModuleStyles(prev => ({
...prev,
[fileName]: {
visible: true,
color: '#3b82f6',
opacity: 0.72,
...(prev[fileName] ?? {}),
...partial,
},
}));
};
const toggleAllModules = () => {
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 stepSlice = (delta: number) => {
setSliceIndex((current) => {
const max = Math.max((dicomPreview?.total ?? selectedProject?.dicomCount ?? 1) - 1, 0);
return Math.max(0, Math.min(max, current + delta));
});
};
const stopSliceStep = () => {
if (sliceRepeatRef.current !== null) {
window.clearInterval(sliceRepeatRef.current);
sliceRepeatRef.current = null;
}
};
const startSliceStep = (delta: number) => {
stopSliceStep();
stepSlice(delta);
sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 95);
};
const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => clampModelPose({
...current,
...partial,
}));
};
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
setModelPose((current) => clampModelPose({
...current,
[key]: clampModelPoseValue(key, current[key] + delta),
}));
};
const resetModelRotationPose = () => {
setModelPose((current) => ({
...current,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
}));
};
const resetModelTransformPose = () => {
setModelPose((current) => ({
...current,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
}));
};
const rotateDicom = (delta: number) => {
setRotation((current) => ((current + delta) % 360 + 360) % 360);
};
const downloadCurrentDicomPng = () => {
if (!dicomPreview || !selectedProject) {
setActionMessage('当前没有可下载的 DICOM 图片');
return;
}
const canvas = document.createElement('canvas');
drawDicomPreviewToCanvas(canvas, dicomPreview, rotation);
const link = document.createElement('a');
const planeLabel = planeOptions.find((option) => option.id === plane)?.label ?? plane;
const modeLabel = displayModes.find((mode) => mode.id === displayMode)?.label ?? displayMode;
link.href = canvas.toDataURL('image/png');
link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${dicomPreview.slice + 1}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
document.body.appendChild(link);
link.click();
link.remove();
setActionMessage('已生成当前 DICOM 图片 PNG');
};
const openDicomInfo = async () => {
if (!selectedProject) return;
setIsDicomInfoOpen(true);
setDicomInfoError('');
try {
setDicomInfo(await api.getDicomInfo(selectedProject.id));
} catch (error) {
setDicomInfo(null);
setDicomInfoError(error instanceof Error ? error.message : 'DICOM 信息查询失败');
}
};
const handleCreateProject = async () => {
const name = newProjectName.trim();
if (!name) {
setActionMessage('请输入项目名称');
return;
}
const created = await api.createProject(name);
setNewProjectName('');
setIsCreateModalOpen(false);
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 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 },
{ id: 'mask' as const, label: '分割结果', icon: Layers },
];
return (
<div className="h-full flex gap-6 overflow-hidden">
{/* Project Sidebar - Collapsible */}
<div
className={`${
isSidebarCollapsed ? 'w-12' : 'w-72'
} flex flex-col bg-white rounded-2xl border border-slate-100 shadow-sm transition-all duration-300 relative overflow-visible shrink-0`}
>
<button
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
className="absolute -right-3 top-1/2 z-10 h-7 w-7 -translate-y-1/2 bg-white border border-slate-100 shadow-md hover:bg-slate-50 rounded-full text-slate-400 transition-colors flex items-center justify-center"
>
{isSidebarCollapsed ? <ChevronRight size={18} /> : <ChevronRight className="rotate-180" size={18} />}
</button>
{!isSidebarCollapsed && (
<div className="p-4 flex flex-col h-full overflow-hidden">
<div className="flex items-center justify-between mb-4 px-1">
<h3 className="font-bold text-slate-800"></h3>
<button
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="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索..."
className="w-full pl-8 pr-4 py-2 bg-slate-50 border-none rounded-lg text-xs focus:ring-1 focus:ring-blue-500 outline-none"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<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) => (
<div
key={proj.id}
onClick={() => setSelectedProject(proj)}
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'
}`}
>
<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()}
onBlur={() => handleEditBlur(proj)}
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-2">
<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>
<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'}`}>
{proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
</p>
</div>
))}
</div>
{actionMessage && <p className="text-[10px] text-slate-400 mt-3 px-1">{actionMessage}</p>}
</div>
)}
{isSidebarCollapsed && (
<div className="flex flex-col items-center py-12 gap-4">
{filteredProjects.map(p => (
<div
key={p.id}
onClick={() => setSelectedProject(p)}
className={`w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${
selectedProject?.id === p.id ? 'bg-blue-600 text-white shadow-md' : 'bg-slate-50 text-slate-400'
}`}
>
<FolderRoot size={16} />
</div>
))}
</div>
)}
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
{selectedProject ? (
<>
<div className="flex items-center justify-between">
<div className="flex bg-slate-100 p-1 rounded-xl">
{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
onClick={() => onReverse(selectedProject.id)}
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-blue-700 transition-all shadow-lg"
>
<RotateCw 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>
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8">
{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">
<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(option.id === 'axial' ? Math.floor((selectedProject.dicomCount || 1) / 2) : 256);
}}
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-16 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
{displayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setDisplayMode(mode.id)}
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
displayMode === mode.id ? 'bg-emerald-600 text-white' : 'text-white/50 hover:text-white'
}`}
>
{mode.label}
</button>
))}
</div>
<div className="absolute top-28 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
<button
onClick={() => rotateDicom(-90)}
className="px-3 py-1.5 rounded-md text-[10px] font-bold text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center gap-1"
title="左旋转 90°"
>
<RotateCcw size={12} />
</button>
<button
onClick={() => rotateDicom(90)}
className="px-3 py-1.5 rounded-md text-[10px] font-bold text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center gap-1"
title="右旋转 90°"
>
<RotateCw size={12} />
</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>
<p>DICOM PATH: {selectedProject.dicomPath}</p>
</div>
<div className={`relative w-full h-full flex items-center justify-center transition-all duration-150 ${isSliceChanging ? 'scale-[1.01] opacity-85 brightness-110' : 'scale-100 opacity-100 brightness-100'}`}>
{dicomPreview ? (
<DicomCanvas preview={dicomPreview} rotation={rotation} />
) : (
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
)}
{isSliceChanging && dicomPreview && (
<span className="absolute right-3 top-3 rounded-md bg-blue-500/20 px-2 py-1 text-[9px] font-bold text-blue-200 backdrop-blur-sm">
</span>
)}
</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} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
<span> {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} </span>
</div>
</div>
{/* Right: Vertical Progress Bar */}
<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} / {sliceTotal || selectedProject.dicomCount}
</span>
<button
onMouseDown={() => startSliceStep(1)}
onMouseUp={stopSliceStep}
onMouseLeave={stopSliceStep}
onTouchStart={(event) => {
event.preventDefault();
startSliceStep(1);
}}
onTouchEnd={stopSliceStep}
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
title="长按向上移动切片"
>
<ChevronUp size={16} />
</button>
<input
type="range"
min="0"
max={Math.max((sliceTotal || selectedProject.dicomCount) - 1, 0)}
value={sliceIndex}
onChange={(e) => setSliceIndex(Number(e.target.value))}
className="flex-1 w-6 accent-blue-600 cursor-pointer"
style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
/>
<button
onMouseDown={() => startSliceStep(-1)}
onMouseUp={stopSliceStep}
onMouseLeave={stopSliceStep}
onTouchStart={(event) => {
event.preventDefault();
startSliceStep(-1);
}}
onTouchEnd={stopSliceStep}
className="mt-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
title="长按向下移动切片"
>
<ChevronDown size={16} />
</button>
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex + 1}</span>
<div className="mt-5 flex w-full flex-col gap-2 px-2">
<button
onClick={downloadCurrentDicomPng}
className="h-8 rounded-lg bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center gap-1 hover:bg-blue-700"
title="下载当前图片 PNG"
>
<Download size={12} /> PNG
</button>
<button
onClick={() => selectedProject && downloadDicomArchive(selectedProject.id)}
className="h-8 rounded-lg bg-white text-slate-600 text-[10px] font-bold flex items-center justify-center gap-1 border border-slate-200 hover:bg-slate-100"
title="下载 DICOM 影像压缩包"
>
<FileArchive size={12} /> DCM
</button>
<button
onClick={openDicomInfo}
className="h-8 rounded-lg bg-white text-slate-600 text-[10px] font-bold flex items-center justify-center gap-1 border border-slate-200 hover:bg-slate-100"
title="查询 DICOM 详细信息"
>
<Info size={12} />
</button>
</div>
</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">
<NativeStlViewer
projectId={selectedProject.id}
files={stlFiles}
styles={moduleStyles}
detailLimit={selectedSolidity.limit}
pose={modelPose}
onPoseChange={setModelPose}
/>
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
</div>
</div>
{/* Right: Sub-module List */}
<div className="w-80 h-full flex flex-col overflow-hidden">
<div className="shrink-0 space-y-4 pb-4">
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-slate-700"></p>
<span className="text-[10px] text-slate-400"> · /Shift · </span>
</div>
<div className="grid grid-cols-4 gap-1 rounded-xl bg-slate-100 p-1">
{solidityOptions.map((option) => (
<button
key={option.id}
onClick={() => setSolidityLevel(option.id)}
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
solidityLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-bold text-slate-700">姿</p>
<div className="flex items-center gap-1">
<button
onClick={resetModelRotationPose}
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
>
姿
</button>
<button
onClick={resetModelTransformPose}
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-slate-600 shadow-sm border border-slate-100 hover:bg-slate-100"
>
姿
</button>
</div>
</div>
{[
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 },
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 },
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ, minus: '-90°', plus: '+90°', delta: 90 },
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX, minus: '-X', plus: '+X', delta: 0.25 },
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY, minus: '-Y', plus: '+Y', delta: 0.25 },
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ, minus: '-Z', plus: '+Z', delta: 0.25 },
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale, minus: '-0.1', plus: '+0.1', delta: 0.1 },
].map((item) => (
<div key={item.key} className="grid grid-cols-[48px_40px_1fr_40px_42px] items-center gap-2">
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
<button
onClick={() => nudgeModelPose(item.key, -item.delta)}
className="h-6 rounded-md bg-white text-[10px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
title={`${item.label} ${item.minus}`}
>
{item.minus}
</button>
<input
type="range"
min={item.min}
max={item.max}
step={item.step}
value={item.value}
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) } as Partial<ModelPose>)}
className="w-full accent-blue-600"
/>
<button
onClick={() => nudgeModelPose(item.key, item.delta)}
className="h-6 rounded-md bg-white text-[10px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
title={`${item.label} ${item.plus}`}
>
{item.plus}
</button>
<span className="text-[10px] font-mono text-slate-400 text-right">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span>
</div>
))}
</div>
</div>
<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"> ({stlFiles.length})</p>
<button
onClick={toggleAllModules}
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">
{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={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' : ''}`}
>
<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">{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">
<button
onClick={(e) => {
e.stopPropagation();
updateModuleStyle(fileName, { visible: !style.visible });
}}
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>
)}
{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>
</>
) : (
<div className="flex-1 bg-white rounded-3xl border border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mb-6">
<FolderRoot size={40} />
</div>
<p className="font-bold"></p>
</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>
)}
{isDicomInfoOpen && (
<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-3xl max-h-[82vh] overflow-hidden rounded-2xl bg-white shadow-2xl border border-slate-100 flex flex-col">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h3 className="font-bold text-slate-900">DICOM </h3>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
<button
onClick={() => setIsDicomInfoOpen(false)}
className="text-slate-400 hover:text-slate-700"
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="overflow-y-auto p-6">
{dicomInfoError && <p className="text-sm font-bold text-rose-600">{dicomInfoError}</p>}
{!dicomInfo && !dicomInfoError && <p className="text-sm text-slate-400"> DICOM ...</p>}
{dicomInfo && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
title: '患者与检查',
rows: [
['患者姓名', dicomInfo.patient.name],
['患者 ID', dicomInfo.patient.id],
['检查日期', dicomInfo.study.date],
['检查类型', dicomInfo.study.modality],
['设备厂商', dicomInfo.study.manufacturer],
],
},
{
title: '序列与文件',
rows: [
['序列描述', dicomInfo.series.description],
['文件数量', dicomInfo.series.files],
['首文件', dicomInfo.series.firstFile],
['末文件', dicomInfo.series.lastFile],
['DICOM 路径', dicomInfo.project.dicomPath],
],
},
{
title: '图像矩阵与窗宽窗位',
rows: [
['Rows', dicomInfo.image.rows],
['Columns', dicomInfo.image.columns],
['Bits Allocated', dicomInfo.image.bitsAllocated],
['Window Center', dicomInfo.image.windowCenter],
['Window Width', dicomInfo.image.windowWidth],
['Rescale', `${dicomInfo.image.rescaleSlope} / ${dicomInfo.image.rescaleIntercept}`],
],
},
{
title: '空间距离',
rows: [
['像素行间距', `${displayDicomValue(dicomInfo.spacing.row)} mm`],
['像素列间距', `${displayDicomValue(dicomInfo.spacing.column)} mm`],
['切片间距', `${displayDicomValue(dicomInfo.spacing.slice)} mm`],
['间距来源', dicomInfo.spacing.sliceSource],
['切片厚度', `${displayDicomValue(dicomInfo.spacing.sliceThickness)} mm`],
['Spacing Between Slices', `${displayDicomValue(dicomInfo.spacing.spacingBetweenSlices)} mm`],
],
},
{
title: '物理尺寸',
rows: [
['宽度', `${displayDicomValue(dicomInfo.physicalSize.width)} ${dicomInfo.physicalSize.unit}`],
['高度', `${displayDicomValue(dicomInfo.physicalSize.height)} ${dicomInfo.physicalSize.unit}`],
['深度', `${displayDicomValue(dicomInfo.physicalSize.depth)} ${dicomInfo.physicalSize.unit}`],
],
},
{
title: '空间位置',
rows: [
['首张位置', dicomInfo.position.firstImagePosition?.join(', ') ?? '未知'],
['末张位置', dicomInfo.position.lastImagePosition?.join(', ') ?? '未知'],
],
},
].map((section) => (
<div key={section.title} className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<h4 className="text-xs font-bold text-slate-800 mb-3">{section.title}</h4>
<div className="space-y-2">
{section.rows.map(([label, value]) => (
<div key={label} className="flex items-start justify-between gap-4 text-xs">
<span className="text-slate-400 shrink-0">{label}</span>
<span className="text-slate-700 font-semibold text-right break-all">{displayDicomValue(value)}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</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>
);
}