2026-05-20-15-20-15 项目库复用逆向视图与用户弹窗调整

This commit is contained in:
2026-05-20 15:32:25 +08:00
parent fd7f3387f7
commit 1f353e97c0
7 changed files with 396 additions and 179 deletions

View File

@@ -9,7 +9,6 @@ import {
Box,
Image as ImageIcon,
Info,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
@@ -22,8 +21,14 @@ import {
Upload
} from 'lucide-react';
import * as THREE from 'three';
import { DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api';
import {
FusionThreeView,
VoxelizationMappingView,
dicomOpacityOptions as reverseDicomOpacityOptions,
displayOptions as reverseDisplayOptions,
} from './ReverseWorkspace';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
@@ -660,7 +665,8 @@ export default function ProjectLibrary({
const [resultRotation, setResultRotation] = useState(0);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [resultDicomPreview, setResultDicomPreview] = useState<DicomPreview | null>(null);
const [resultFusionVolume, setResultFusionVolume] = useState<DicomFusionVolume | null>(null);
const [resultFusionError, setResultFusionError] = useState('');
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(null);
const [dicomInfoError, setDicomInfoError] = useState('');
const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false);
@@ -756,7 +762,10 @@ export default function ProjectLibrary({
const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles;
const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice));
const resultFineDetailLimit = solidityOptions.find((option) => option.id === 'fine')?.limit ?? 36000;
const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0];
const resultDicomOpacity = reverseDicomOpacityOptions.find((option) => option.id === 'high') ?? reverseDicomOpacityOptions[reverseDicomOpacityOptions.length - 1];
const resultCutStart = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceStart ?? 0));
const resultCutEnd = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceEnd ?? resultMaxSlice));
const resultVisibleModules = stlFiles
.map((fileName, index) => ({
fileName,
@@ -872,30 +881,33 @@ export default function ProjectLibrary({
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]);
useEffect(() => {
if (!selectedProject || viewMode !== 'mask' || !selectedProject.dicomCount) {
setResultDicomPreview(null);
if (!selectedProject || viewMode !== 'mask' || !latestSegmentationResult || !selectedProject.dicomCount) {
setResultFusionVolume(null);
setResultFusionError('');
return;
}
let cancelled = false;
const maxSlice = Math.max(selectedProject.dicomCount - 1, 0);
const previewSlice = Math.max(0, Math.min(maxSlice, resultPreviewSlice));
api.getDicomPreview(selectedProject.id, previewSlice, 'axial', resultDisplayMode)
.then((preview) => {
const start = Math.min(resultCutStart, resultCutEnd);
const end = Math.max(resultCutStart, resultCutEnd);
setResultFusionError('');
api.getDicomFusionVolume(selectedProject.id, start, end, 'soft')
.then((volume) => {
if (!cancelled) {
setResultDicomPreview(preview);
setResultFusionVolume(volume);
}
})
.catch(() => {
.catch((error) => {
if (!cancelled) {
setResultDicomPreview(null);
setResultFusionVolume(null);
setResultFusionError(error instanceof Error ? error.message : 'DICOM 三维融合体载入失败');
}
});
return () => {
cancelled = true;
};
}, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, resultPreviewSlice, resultDisplayMode]);
}, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, resultCutStart, resultCutEnd]);
useEffect(() => () => {
if (sliceRepeatRef.current !== null) {
@@ -1554,136 +1566,87 @@ export default function ProjectLibrary({
)}
{viewMode === 'mask' && (
<div className="h-full grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="grid min-h-0 grid-cols-1 gap-4 lg:grid-cols-2">
<div className="relative min-h-[520px] overflow-hidden rounded-2xl border border-slate-900 bg-slate-950 text-white shadow-sm">
<div className="absolute left-4 top-4 z-10 rounded-xl border border-white/10 bg-black/45 px-3 py-2 backdrop-blur">
<p className="text-sm font-bold"></p>
<p className="mt-1 font-mono text-[10px] text-white/45">
{latestSegmentationResult ? `模型显示 精细 · 融合显示 DICOM 高 · Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'}
</p>
<div className="h-full grid grid-cols-1 gap-6 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_340px]">
<div className="min-h-[560px]">
{latestSegmentationResult ? (
<FusionThreeView
project={selectedProject}
volume={resultFusionVolume}
modelPose={latestResultPose}
moduleStyles={latestResultStyles}
detailLimit={resultDisplayOption.limit}
solidMode={false}
dicomOpacity={resultDicomOpacity}
showBounds={latestSegmentationResult.showBounds ?? true}
cutEnabled={latestSegmentationResult.cutEnabled ?? false}
cutStart={resultCutStart}
cutEnd={resultCutEnd}
/>
) : (
<div className="flex h-full min-h-[560px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
</div>
{latestSegmentationResult ? (
<NativeStlViewer
projectId={selectedProject.id}
files={stlFiles}
styles={latestResultStyles}
detailLimit={resultFineDetailLimit}
solidMode={false}
pose={latestResultPose}
onPoseChange={setResultPose}
/>
) : (
<div className="flex h-full items-center justify-center px-8 text-center text-sm font-bold text-white/35">
</div>
)}
</div>
)}
{resultFusionError && (
<p className="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-bold text-amber-700">
{resultFusionError}
</p>
)}
</div>
<div className="relative flex min-h-[520px] flex-col overflow-hidden rounded-2xl border border-slate-900 bg-black text-white shadow-sm">
<div className="relative min-h-0 flex-1">
<div className="absolute left-4 top-4 z-10 flex flex-wrap gap-2">
<span className="rounded-lg border border-white/10 bg-black/50 px-2.5 py-1 font-mono text-[10px] font-bold text-white/70 backdrop-blur">
BASE DICOM
</span>
<span className="rounded-lg border border-cyan-300/40 bg-cyan-400/15 px-2.5 py-1 font-mono text-[10px] font-bold text-cyan-100 backdrop-blur">
OVERLAY LABEL MAP
</span>
<span className="rounded-lg border border-white/10 bg-black/50 px-2.5 py-1 font-mono text-[10px] font-bold text-white/70 backdrop-blur">
Z {resultMappingSlice + 1}/{selectedProject.dicomCount}
</span>
</div>
<div className="absolute right-4 top-4 z-10 flex flex-wrap justify-end gap-1.5">
<div className="flex rounded-lg border border-white/10 bg-black/50 p-1 backdrop-blur">
{displayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setResultDisplayMode(mode.id)}
className={`rounded-md px-2 py-1 text-[9px] font-bold transition ${resultDisplayMode === mode.id ? 'bg-cyan-400 text-slate-950' : 'text-white/55 hover:text-white'}`}
>
{mode.label}
</button>
))}
</div>
<button
onClick={() => setResultRotation((value) => (value + 270) % 360)}
className="rounded-lg border border-white/10 bg-black/50 px-2 py-1 text-[9px] font-bold text-white/70 backdrop-blur hover:text-white"
title="左转 90°"
>
<RotateCcw size={12} />
</button>
<button
onClick={() => setResultRotation((value) => (value + 90) % 360)}
className="rounded-lg border border-white/10 bg-black/50 px-2 py-1 text-[9px] font-bold text-white/70 backdrop-blur hover:text-white"
title="右转 90°"
>
<RotateCw size={12} />
</button>
</div>
<div className="absolute inset-0 flex items-center justify-center p-8">
{latestSegmentationResult && resultDicomPreview ? (
<div className="relative flex h-full w-full items-center justify-center">
<DicomCanvas preview={resultDicomPreview} rotation={resultRotation} />
<div className="pointer-events-none absolute inset-x-10 bottom-10 rounded-2xl border border-white/10 bg-black/55 p-3 backdrop-blur">
<div className="mb-2 flex items-center justify-between gap-3 text-[11px] font-bold text-white/70">
<span></span>
<span className="font-mono text-cyan-100">{resultVisibleModules.length} </span>
</div>
<div className="grid grid-cols-2 gap-2">
{resultVisibleModules.slice(0, 8).map(({ fileName, name, style }) => (
<div key={fileName} className="flex min-w-0 items-center gap-2 rounded-lg bg-white/5 px-2 py-1">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} />
<span className="min-w-0 flex-1 truncate text-[10px] font-bold text-white/75">{name}</span>
<span className="font-mono text-[9px] text-white/45">ID {style.partId}</span>
</div>
))}
</div>
</div>
</div>
) : (
<p className="text-center text-sm font-bold text-white/30">
{latestSegmentationResult ? '正在载入逆向分割映射视图...' : '暂无逆向分割映射视图'}
</p>
)}
</div>
</div>
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Slice Navigator</p>
<span className="font-mono text-[10px] font-bold text-cyan-100">
{resultMappingSlice + 1} / {Math.max(selectedProject.dicomCount, 1)}
</span>
</div>
<div className="grid grid-cols-[28px_1fr_28px] items-center gap-3">
<button
onClick={() => setResultPreviewSlice((value) => Math.max(0, value - 1))}
disabled={!latestSegmentationResult || resultMappingSlice <= 0}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="上一层"
>
<ChevronLeft size={15} />
</button>
<input
type="range"
min="0"
max={resultMaxSlice}
value={resultMappingSlice}
disabled={!latestSegmentationResult}
onChange={(event) => setResultPreviewSlice(Number(event.target.value))}
className="h-2 w-full accent-cyan-400 disabled:opacity-35"
aria-label="项目库逆向分割结果切片导航"
/>
<button
onClick={() => setResultPreviewSlice((value) => Math.min(resultMaxSlice, value + 1))}
disabled={!latestSegmentationResult || resultMappingSlice >= resultMaxSlice}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="下一层"
>
<ChevronRight size={15} />
</button>
<div className="min-h-[560px]">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 font-bold text-slate-700">
<Layers size={18} className="text-cyan-500" />
</h3>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<div className="flex rounded-xl bg-slate-100 p-1">
{displayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setResultDisplayMode(mode.id)}
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
resultDisplayMode === mode.id ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{mode.label}
</button>
))}
</div>
<button
onClick={() => setResultRotation((value) => (value + 270) % 360)}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
title="左转 90°"
>
<RotateCcw size={14} />
</button>
<button
onClick={() => setResultRotation((value) => (value + 90) % 360)}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
title="右转 90°"
>
<RotateCw size={14} />
</button>
</div>
</div>
{latestSegmentationResult ? (
<VoxelizationMappingView
project={selectedProject}
moduleStyles={latestResultStyles}
modelPose={latestResultPose}
detailLimit={resultDisplayOption.limit}
slice={resultMappingSlice}
totalSlices={selectedProject.dicomCount}
onSliceChange={setResultPreviewSlice}
displayMode={resultDisplayMode}
rotation={resultRotation}
/>
) : (
<div className="flex h-full min-h-[520px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
</div>
)}
</div>
<div className="flex flex-col gap-4">