1551 lines
67 KiB
TypeScript
1551 lines
67 KiB
TypeScript
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 格式分割 mask。NII.GZ 为默认全量导出格式,适合后续医学影像工具链读取。
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => downloadMask(selectedProject.id, 'nii.gz')}
|
||
className="bg-slate-900 text-white px-5 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-black"
|
||
>
|
||
<Download size={18} /> 下载 NII.GZ
|
||
</button>
|
||
<button
|
||
onClick={() => downloadMask(selectedProject.id, 'nii')}
|
||
className="bg-white text-slate-700 px-5 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-50 border border-slate-200"
|
||
>
|
||
<Download size={18} /> 下载 NII
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|