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

@@ -8,7 +8,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import Login from './components/Login';
import Sidebar from './components/Sidebar';
@@ -25,6 +25,8 @@ export default function App() {
const [activeView, setActiveView] = useState<ViewType>(ViewType.OVERVIEW);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [activeProjectId, setActiveProjectId] = useState('head-ct-demo');
const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom');
const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null);
// Automatically collapse main sidebar when entering Project Library or Workspace
useEffect(() => {
@@ -71,7 +73,40 @@ export default function App() {
setIsAuthenticated(true);
};
const requestActiveView = (nextView: ViewType) => {
if (nextView === activeView) {
return;
}
const leaveWorkspace = activeView === ViewType.WORKSPACE && nextView !== ViewType.WORKSPACE;
const switchView = () => {
if (leaveWorkspace && nextView === ViewType.PROJECTS) {
setProjectLibraryInitialView('mask');
}
setActiveView(nextView);
};
if (!leaveWorkspace || !workspaceLeaveGuardRef.current) {
switchView();
return;
}
workspaceLeaveGuardRef.current()
.then((canLeave) => {
if (canLeave) {
switchView();
}
})
.catch(() => undefined);
};
const handleLogout = async () => {
if (activeView === ViewType.WORKSPACE && workspaceLeaveGuardRef.current) {
const canLeave = await workspaceLeaveGuardRef.current();
if (!canLeave) {
return;
}
}
await api.logout();
setIsAuthenticated(false);
setActiveView(ViewType.OVERVIEW);
@@ -93,7 +128,7 @@ export default function App() {
<div className="flex h-screen bg-[#f8fafc] overflow-hidden font-sans antialiased text-slate-900">
<Sidebar
activeView={activeView}
setActiveView={setActiveView}
setActiveView={requestActiveView}
onLogout={handleLogout}
collapsed={sidebarCollapsed}
setCollapsed={setSidebarCollapsed}
@@ -129,13 +164,21 @@ export default function App() {
{activeView === ViewType.OVERVIEW && <Overview />}
{activeView === ViewType.PROJECTS && (
<ProjectLibrary
initialViewMode={projectLibraryInitialView}
onReverse={(projectId) => {
setActiveProjectId(projectId);
setActiveView(ViewType.WORKSPACE);
}}
/>
)}
{activeView === ViewType.WORKSPACE && <ReverseWorkspace projectId={activeProjectId} />}
{activeView === ViewType.WORKSPACE && (
<ReverseWorkspace
projectId={activeProjectId}
onLeaveGuardChange={(handler) => {
workspaceLeaveGuardRef.current = handler;
}}
/>
)}
{activeView === ViewType.SYSTEM && <UserManagement />}
</motion.div>
</AnimatePresence>

View File

@@ -22,7 +22,7 @@ import {
} from 'lucide-react';
import * as THREE from 'three';
import { DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
import { api, downloadDicomArchive, downloadMask, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api';
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
@@ -655,6 +655,7 @@ export default function ProjectLibrary({
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [resultDicomPreview, setResultDicomPreview] = useState<DicomPreview | null>(null);
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(null);
const [dicomInfoError, setDicomInfoError] = useState('');
const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false);
@@ -746,6 +747,23 @@ export default function ProjectLibrary({
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
const latestResultPose = latestSegmentationResult?.pose ?? modelPose;
const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles;
const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.mappingSlice ?? resultMaxSlice));
const resultVisibleModules = stlFiles
.map((fileName, index) => ({
fileName,
name: fileName.replace(/\.stl$/i, ''),
style: latestResultStyles[fileName] ?? {
visible: true,
color: defaultModuleColors[index % defaultModuleColors.length],
opacity: 0.72,
partId: index + 1,
},
}))
.filter(({ style }) => style.visible !== false);
const readonlyPoseChange = useMemo<React.Dispatch<React.SetStateAction<ModelPose>>>(() => () => undefined, []);
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
visible: fallback?.visible ?? true,
@@ -802,13 +820,14 @@ export default function ProjectLibrary({
}, [initialViewMode]);
useEffect(() => {
const latestResult = selectedProject?.segmentationResults?.[selectedProject.segmentationResults.length - 1];
const next: Record<string, ModuleStyle> = {};
stlFiles.forEach((fileName, index) => {
next[fileName] = makeDefaultModuleStyle(index, selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
});
setModuleStyles(next);
setSliceIndex(0);
setModelPose(defaultModelPose);
setModelPose(latestResult?.pose ?? defaultModelPose);
}, [selectedProject?.id]);
useEffect(() => {
@@ -843,6 +862,32 @@ export default function ProjectLibrary({
};
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]);
useEffect(() => {
if (!selectedProject || viewMode !== 'mask' || !selectedProject.dicomCount) {
setResultDicomPreview(null);
return;
}
let cancelled = false;
const maxSlice = Math.max(selectedProject.dicomCount - 1, 0);
const previewSlice = Math.max(0, Math.min(maxSlice, latestSegmentationResult?.mappingSlice ?? maxSlice));
api.getDicomPreview(selectedProject.id, previewSlice, 'axial', 'soft')
.then((preview) => {
if (!cancelled) {
setResultDicomPreview(preview);
}
})
.catch(() => {
if (!cancelled) {
setResultDicomPreview(null);
}
});
return () => {
cancelled = true;
};
}, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, latestSegmentationResult?.mappingSlice]);
useEffect(() => () => {
if (sliceRepeatRef.current !== null) {
window.clearInterval(sliceRepeatRef.current);
@@ -1026,7 +1071,7 @@ export default function ProjectLibrary({
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 },
{ id: 'mask' as const, label: '逆向分割结果', icon: Layers },
];
return (
@@ -1500,61 +1545,104 @@ export default function ProjectLibrary({
)}
{viewMode === 'mask' && (
<div className="h-full grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
<div className="rounded-2xl border border-slate-100 bg-slate-950 p-4 text-white shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="font-bold"></h3>
<p className="mt-1 text-[11px] font-bold text-white/40">
Label Map
<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 ? `Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'}
</p>
</div>
<span className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 font-mono text-[10px] text-cyan-100">
{savedSegmentationResults.length}
</span>
{latestSegmentationResult ? (
<NativeStlViewer
projectId={selectedProject.id}
files={stlFiles}
styles={latestResultStyles}
detailLimit={selectedSolidity.limit}
solidMode={solidityLevel === 'solid'}
pose={latestResultPose}
onPoseChange={readonlyPoseChange}
/>
) : (
<div className="flex h-full items-center justify-center px-8 text-center text-sm font-bold text-white/35">
</div>
)}
</div>
{savedSegmentationResults.length ? (
<div className="grid gap-3 md:grid-cols-2">
{savedSegmentationResults.map((result, index) => (
<div key={result.id} className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm font-bold">{result.name}</p>
<span className="rounded bg-cyan-400/15 px-1.5 py-0.5 font-mono text-[9px] text-cyan-100">
#{index + 1}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-[10px] font-bold text-white/45">
<span>{result.segmentationScope === 'all' ? '所有类别' : '可见类别'}</span>
<span className="text-right">{new Date(result.createdAt).toLocaleString('zh-CN', { hour12: false })}</span>
<span className="font-mono">RX {result.pose.rotateX.toFixed(0)}°</span>
<span className="text-right font-mono">TZ {result.pose.translateZ.toFixed(3)}</span>
<div className="relative min-h-[520px] overflow-hidden rounded-2xl border border-slate-900 bg-black text-white shadow-sm">
<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 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={0} />
<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 className="flex h-[420px] items-center justify-center rounded-2xl border border-dashed border-white/15 bg-white/[0.03] px-6 text-center text-sm font-bold text-white/35">
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4">
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-5">
<h3 className="mb-3 font-bold text-slate-800"></h3>
<p className="text-sm leading-6 text-slate-500">
使姿 DICOMLabel Map姿 STL
</p>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-bold text-slate-800"></h3>
<p className="mt-2 text-sm leading-6 text-slate-500">
沿姿
</p>
</div>
<span className={`rounded-lg px-2 py-1 text-[10px] font-bold ${latestSegmentationResult ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'}`}>
{latestSegmentationResult ? '已保存' : '未保存'}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
<span className="rounded-lg bg-white px-2 py-2">{selectedProject.dicomCount ? `${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '--'}</span>
<span className="rounded-lg bg-white px-2 py-2">{latestSegmentationResult?.segmentationScope === 'all' ? '所有类别' : '可见类别'}</span>
<span className="rounded-lg bg-white px-2 py-2">{resultVisibleModules.length}</span>
<span className="rounded-lg bg-white px-2 py-2">
{latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'}
</span>
</div>
</div>
<div className="relative">
<button
onClick={() => setShowMaskExportMenu((value) => !value)}
disabled={maskExporting}
disabled={maskExporting || !latestSegmentationResult}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white shadow-lg hover:bg-emerald-700 disabled:opacity-50"
>
<Download size={18} />
{maskExporting ? '正在导出' : '导出全部 NII.GZ'}
{maskExporting ? '正在导出' : '导出项目及结果'}
</button>
{showMaskExportMenu && (
<div className="absolute right-0 top-14 z-30 w-full rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
@@ -1619,12 +1707,6 @@ export default function ProjectLibrary({
</div>
)}
</div>
<button
onClick={() => downloadMask(selectedProject.id, 'nii.gz', latestSegmentationResult?.pose ?? modelPose, maskSegmentationScope)}
className="flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-bold text-slate-700 hover:bg-slate-50"
>
<Download size={18} /> NII.GZ
</button>
</div>
</div>
)}

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}

View File

@@ -1,4 +1,4 @@
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationExportScope, SessionState, UserRecord } from '../types';
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
export type { SegmentationExportScope } from '../types';
@@ -70,6 +70,13 @@ export const api = {
pose: ModelPose;
segmentationScope: SegmentationExportScope;
moduleStyles: Record<string, ModuleStyle>;
sliceStart?: number;
sliceEnd?: number;
mappingSlice?: number;
displayLevel?: SegmentationDisplayLevel;
dicomOpacityLevel?: SegmentationDicomOpacityLevel;
showBounds?: boolean;
cutEnabled?: boolean;
},
) =>
request<Project>(`/api/projects/${projectId}/segmentation-results`, {

View File

@@ -49,6 +49,8 @@ export interface SavedModelPose {
}
export type SegmentationExportScope = 'all' | 'visible';
export type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
export interface SegmentationResult {
id: string;
@@ -57,6 +59,13 @@ export interface SegmentationResult {
segmentationScope: SegmentationExportScope;
pose: ModelPose;
moduleStyles: Record<string, ModuleStyle>;
sliceStart?: number;
sliceEnd?: number;
mappingSlice?: number;
displayLevel?: SegmentationDisplayLevel;
dicomOpacityLevel?: SegmentationDicomOpacityLevel;
showBounds?: boolean;
cutEnabled?: boolean;
}
export interface MaskMapping {