2026-05-20-14-19-23 逆向分割结果流程调整

This commit is contained in:
2026-05-20 14:38:01 +08:00
parent 6a50287a2a
commit 2a599695e9
10 changed files with 616 additions and 124 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Settings2,
Download,
@@ -13,7 +13,7 @@ import {
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
import { api, downloadMask, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api';
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api';
interface ModelPreviewPayload {
fileName: string;
@@ -39,6 +39,7 @@ interface AxisVector2D {
}
type AxisProjection = Record<AxisKey, AxisVector2D>;
type WorkspaceLeaveGuard = () => Promise<boolean>;
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
@@ -1930,7 +1931,13 @@ function VoxelizationMappingView({
);
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
export default function ReverseWorkspace({
projectId,
onLeaveGuardChange,
}: {
projectId: string;
onLeaveGuardChange?: (handler: WorkspaceLeaveGuard | null) => void;
}) {
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49);
const [mappingSlice, setMappingSlice] = useState(0);
@@ -1961,17 +1968,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
const handleExport = async (format: 'nii' | 'nii.gz') => {
setExporting(true);
try {
await downloadMask(projectId, format, modelPose, segmentationExportScope);
} catch (error) {
setFusionError(error instanceof Error ? error.message : '导出失败');
} finally {
setExporting(false);
}
};
const saveToastTimerRef = useRef<number | null>(null);
const handleExportSelected = async () => {
const selectedItems = exportOptions
@@ -1997,26 +1994,93 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
}
};
const handleSaveSegmentationResult = async () => {
const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => {
if (!project) {
return;
return false;
}
setFusionError('');
setSaveStatus('');
try {
const updated = await api.saveProjectSegmentationResult(project.id, {
name: `分割结果 ${new Date().toLocaleString('zh-CN', { hour12: false })}`,
name: '逆向分割结果',
pose: modelPose,
segmentationScope: segmentationExportScope,
moduleStyles,
sliceStart: clamp(sliceStart, 0, Math.max(project.dicomCount - 1, 0)),
sliceEnd: clamp(sliceEnd, 0, Math.max(project.dicomCount - 1, 0)),
mappingSlice: clamp(mappingSlice, 0, Math.max(project.dicomCount - 1, 0)),
displayLevel,
dicomOpacityLevel,
showBounds,
cutEnabled,
});
setProject(updated);
setSaveStatus('已保存至项目库的分割结果区域');
if (options.showToast !== false) {
setSaveStatus('已保存至项目库的分割结果区域');
}
return true;
} catch (error) {
setFusionError(error instanceof Error ? error.message : '保存至项目库失败');
const message = error instanceof Error ? error.message : '保存至项目库失败';
setFusionError(message);
if (options.showToast === false) {
window.alert(message);
}
return false;
}
};
}, [
project,
modelPose,
segmentationExportScope,
moduleStyles,
sliceStart,
sliceEnd,
mappingSlice,
displayLevel,
dicomOpacityLevel,
showBounds,
cutEnabled,
]);
useEffect(() => {
if (!saveStatus) {
return undefined;
}
if (saveToastTimerRef.current !== null) {
window.clearTimeout(saveToastTimerRef.current);
}
saveToastTimerRef.current = window.setTimeout(() => {
setSaveStatus('');
saveToastTimerRef.current = null;
}, 2600);
return () => {
if (saveToastTimerRef.current !== null) {
window.clearTimeout(saveToastTimerRef.current);
saveToastTimerRef.current = null;
}
};
}, [saveStatus]);
useEffect(() => {
if (!onLeaveGuardChange) {
return undefined;
}
onLeaveGuardChange(async () => {
if (!project) {
return true;
}
const shouldSave = window.confirm('是否保存当前结果至项目库?\\n确定保存后退出。\\n取消直接退出不保存当前结果。');
if (!shouldSave) {
return true;
}
return handleSaveSegmentationResult({ showToast: false });
});
return () => onLeaveGuardChange(null);
}, [handleSaveSegmentationResult, onLeaveGuardChange, project]);
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
visible: fallback?.visible ?? true,
@@ -2063,19 +2127,30 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
api.getProject(projectId).then((item) => {
setProject(item);
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
setSliceStart(0);
setSliceEnd(maxIndex);
setMappingSlice(maxIndex);
const latestResult = item.segmentationResults?.[item.segmentationResults.length - 1];
const restoredSliceStart = clamp(latestResult?.sliceStart ?? 0, 0, maxIndex);
const restoredSliceEnd = clamp(latestResult?.sliceEnd ?? maxIndex, 0, maxIndex);
const restoredMappingSlice = clamp(latestResult?.mappingSlice ?? restoredSliceEnd, 0, maxIndex);
setSliceStart(restoredSliceStart);
setSliceEnd(restoredSliceEnd);
setMappingSlice(restoredMappingSlice);
const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses;
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
setModelPose(preferredPose?.pose ?? defaultModelPose);
const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose;
setModelPose(restoredPose);
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? item.moduleStyles?.[fileName]);
});
setModuleStyles(nextStyles);
setSavedPoses(nextPoses);
setSelectedPoseId(preferredPose?.id ?? 'default');
setSelectedPoseId(latestResult ? 'reverse-result' : preferredPose?.id ?? 'default');
setSegmentationExportScope(latestResult?.segmentationScope ?? 'visible');
setDisplayLevel(latestResult?.displayLevel ?? 'standard');
setDicomOpacityLevel(latestResult?.dicomOpacityLevel ?? 'low');
setShowBounds(latestResult?.showBounds ?? true);
setCutEnabled(latestResult?.cutEnabled ?? false);
}).catch(() => {
setProject(null);
setFusionVolume(null);
@@ -2336,6 +2411,19 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
return (
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
{saveStatus && (
<>
<style>
{`@keyframes reverse-result-toast { 0% { opacity: 0; transform: translate(-50%, -10px); } 14% { opacity: 1; transform: translate(-50%, 0); } 72% { opacity: 1; transform: translate(-50%, 0); } 100% { opacity: 0; transform: translate(-50%, -10px); } }`}
</style>
<div
className="fixed left-1/2 top-20 z-50 rounded-2xl border border-cyan-200 bg-white px-5 py-3 text-sm font-bold text-cyan-700 shadow-2xl shadow-cyan-950/10"
style={{ animation: 'reverse-result-toast 2.6s ease forwards' }}
>
{saveStatus}
</div>
</>
)}
<div className="flex items-center justify-between">
<div>
{project && (
@@ -2348,6 +2436,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
{!project && <p className="text-sm text-slate-500"> DICOM </p>}
</div>
<div className="flex gap-2">
<button
onClick={() => void handleSaveSegmentationResult()}
disabled={!project}
className="bg-cyan-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-cyan-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
<Save size={18} />
</button>
<div className="relative">
<button
onClick={() => setShowExportMenu((value) => !value)}
@@ -2355,7 +2451,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
<Download size={18} />
{exporting ? '正在导出' : '导出全部 NII.GZ'}
{exporting ? '正在导出' : '导出项目及结果'}
</button>
{showExportMenu && (
<div className="absolute right-0 top-12 z-30 w-72 rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
@@ -2799,38 +2895,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<Layers size={18} className="text-cyan-500" />
</h3>
<div className="flex flex-wrap gap-2">
<button
onClick={handleSaveSegmentationResult}
disabled={!project}
className="bg-cyan-50 hover:bg-cyan-100 text-cyan-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-cyan-100 flex items-center gap-1 disabled:opacity-50"
>
<Save size={12} />
</button>
<button
onClick={() => handleExport('nii')}
disabled={exporting}
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1 disabled:opacity-50"
>
<Download size={12} />
NII
</button>
<button
onClick={() => handleExport('nii.gz')}
disabled={exporting}
className="bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg disabled:opacity-50"
>
<Download size={12} />
NII.GZ
</button>
</div>
</div>
{saveStatus && (
<div className="rounded-xl border border-cyan-100 bg-cyan-50 px-3 py-2 text-[10px] font-bold text-cyan-700">
{saveStatus}
</div>
)}
<VoxelizationMappingView
project={project}