2026-05-20-14-19-23 逆向分割结果流程调整
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user