2026-05-07-16-20-46 修正DICOM比例和3D默认位姿
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
RotateCcw,
|
||||
Box,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
@@ -20,12 +21,12 @@ import {
|
||||
Upload
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomPreview, Project } from '../types';
|
||||
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';
|
||||
type SolidityLevel = 'preview' | 'standard' | 'fine' | 'ultra';
|
||||
|
||||
interface ModuleStyle {
|
||||
visible: boolean;
|
||||
@@ -41,7 +42,6 @@ interface ModelPose {
|
||||
translateY: number;
|
||||
translateZ: number;
|
||||
scale: number;
|
||||
autoRotate: boolean;
|
||||
}
|
||||
|
||||
interface ModelPreviewPayload {
|
||||
@@ -56,6 +56,7 @@ 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,
|
||||
@@ -65,7 +66,6 @@ const defaultModelPose: ModelPose = {
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
autoRotate: true,
|
||||
};
|
||||
|
||||
function drawFallbackModelPreview(
|
||||
@@ -181,6 +181,13 @@ 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);
|
||||
|
||||
@@ -205,18 +212,19 @@ function NativeStlViewer({
|
||||
files,
|
||||
styles,
|
||||
detailLimit,
|
||||
solidWhite,
|
||||
pose,
|
||||
onPoseChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
files: string[];
|
||||
styles: Record<string, ModuleStyle>;
|
||||
detailLimit: number;
|
||||
solidWhite: boolean;
|
||||
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('准备加载模型');
|
||||
|
||||
@@ -224,6 +232,92 @@ function NativeStlViewer({
|
||||
poseRef.current = pose;
|
||||
}, [pose]);
|
||||
|
||||
useEffect(() => {
|
||||
onPoseChangeRef.current = onPoseChange;
|
||||
}, [onPoseChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const clampPose = (next: ModelPose): ModelPose => ({
|
||||
rotateX: Math.max(-180, Math.min(180, next.rotateX)),
|
||||
rotateY: Math.max(-180, Math.min(180, next.rotateY)),
|
||||
rotateZ: Math.max(-180, Math.min(180, next.rotateZ)),
|
||||
translateX: Math.max(-2, Math.min(2, next.translateX)),
|
||||
translateY: Math.max(-2, Math.min(2, next.translateY)),
|
||||
translateZ: Math.max(-2, Math.min(2, next.translateZ)),
|
||||
scale: Math.max(0.5, Math.min(2.5, next.scale)),
|
||||
});
|
||||
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(clampPose({
|
||||
...dragState.startPose,
|
||||
translateX: dragState.startPose.translateX + deltaX * 0.006,
|
||||
translateY: dragState.startPose.translateY - deltaY * 0.006,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
onPoseChangeRef.current(clampPose({
|
||||
...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(clampPose({
|
||||
...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;
|
||||
@@ -242,7 +336,8 @@ function NativeStlViewer({
|
||||
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.position.set(4.5, 3.5, 5);
|
||||
camera.up.set(0, 1, 0);
|
||||
camera.position.set(0, 0, 6);
|
||||
camera.lookAt(0, 0, 0);
|
||||
let renderer: THREE.WebGLRenderer | null = null;
|
||||
try {
|
||||
@@ -263,10 +358,7 @@ function NativeStlViewer({
|
||||
})
|
||||
.then((payload) => ({
|
||||
payload,
|
||||
style: {
|
||||
...(styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }),
|
||||
color: solidWhite ? '#f4f4f2' : styles[fileName]?.color ?? '#3b82f6',
|
||||
},
|
||||
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true },
|
||||
})),
|
||||
),
|
||||
).then((results) => {
|
||||
@@ -308,7 +400,6 @@ function NativeStlViewer({
|
||||
|
||||
const group = new THREE.Group();
|
||||
let baseScale = 1;
|
||||
let autoSpin = 0;
|
||||
scene.add(group);
|
||||
let loaded = 0;
|
||||
let failed = 0;
|
||||
@@ -330,11 +421,11 @@ function NativeStlViewer({
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: solidWhite ? '#f4f4f2' : style.color,
|
||||
color: style.color,
|
||||
opacity: style.opacity,
|
||||
transparent: style.opacity < 1,
|
||||
roughness: solidWhite ? 0.34 : 0.48,
|
||||
metalness: solidWhite ? 0.02 : 0.08,
|
||||
roughness: 0.42,
|
||||
metalness: 0.04,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
@@ -381,12 +472,9 @@ function NativeStlViewer({
|
||||
const animate = () => {
|
||||
if (disposed) return;
|
||||
const currentPose = poseRef.current;
|
||||
if (currentPose.autoRotate) {
|
||||
autoSpin += 0.004;
|
||||
}
|
||||
group.rotation.set(
|
||||
THREE.MathUtils.degToRad(currentPose.rotateX),
|
||||
THREE.MathUtils.degToRad(currentPose.rotateY) + autoSpin,
|
||||
THREE.MathUtils.degToRad(currentPose.rotateY),
|
||||
THREE.MathUtils.degToRad(currentPose.rotateZ),
|
||||
);
|
||||
group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
|
||||
@@ -414,10 +502,10 @@ function NativeStlViewer({
|
||||
});
|
||||
container.innerHTML = '';
|
||||
};
|
||||
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidWhite]);
|
||||
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
<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">
|
||||
@@ -452,10 +540,12 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [isSliceChanging, setIsSliceChanging] = useState(false);
|
||||
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
|
||||
const [solidWhite, setSolidWhite] = useState(true);
|
||||
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);
|
||||
@@ -619,7 +709,6 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
const updateModelPose = (partial: Partial<ModelPose>) => {
|
||||
setModelPose((current) => ({
|
||||
...current,
|
||||
autoRotate: partial.autoRotate ?? false,
|
||||
...partial,
|
||||
}));
|
||||
};
|
||||
@@ -651,6 +740,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
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) {
|
||||
@@ -992,6 +1093,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
>
|
||||
<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>
|
||||
@@ -1006,8 +1114,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
files={stlFiles}
|
||||
styles={moduleStyles}
|
||||
detailLimit={selectedSolidity.limit}
|
||||
solidWhite={solidWhite}
|
||||
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}
|
||||
@@ -1019,14 +1127,9 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<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>
|
||||
<button
|
||||
onClick={resetModelPose}
|
||||
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
重置位姿
|
||||
</button>
|
||||
<span className="text-[10px] text-slate-400">左键旋转 · 右键/Shift 平移 · 滚轮缩放</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1 mb-3">
|
||||
<div className="grid grid-cols-4 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{solidityOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
@@ -1039,28 +1142,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setSolidWhite((current) => !current)}
|
||||
className={`rounded-xl px-3 py-2 text-[10px] font-bold border transition-all ${
|
||||
solidWhite ? 'bg-white text-slate-800 border-slate-200 shadow-sm' : 'bg-transparent text-slate-500 border-slate-200'
|
||||
}`}
|
||||
>
|
||||
白色实体
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateModelPose({ autoRotate: !modelPose.autoRotate })}
|
||||
className={`rounded-xl px-3 py-2 text-[10px] font-bold border transition-all ${
|
||||
modelPose.autoRotate ? 'bg-blue-600 text-white border-blue-600 shadow-sm' : 'bg-transparent text-slate-500 border-slate-200'
|
||||
}`}
|
||||
>
|
||||
自动旋转
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
|
||||
<p className="text-xs font-bold text-slate-700">整体位姿</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold text-slate-700">整体位姿</p>
|
||||
<button
|
||||
onClick={resetModelPose}
|
||||
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
重置位姿
|
||||
</button>
|
||||
</div>
|
||||
{[
|
||||
{ key: 'rotateX', label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
|
||||
{ key: 'rotateY', label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
|
||||
@@ -1068,7 +1161,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
{ key: 'translateX', label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
|
||||
{ key: 'translateY', label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
|
||||
{ key: 'translateZ', label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
|
||||
{ key: 'scale', label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
|
||||
{ key: 'scale', label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="grid grid-cols-[48px_1fr_42px] items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
|
||||
@@ -1246,6 +1339,105 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user