2026-05-20-14-19-23 逆向分割结果流程调整
This commit is contained in:
@@ -11,6 +11,8 @@ type DicomPlane = 'axial' | 'sagittal' | 'coronal';
|
||||
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
|
||||
type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
|
||||
type SegmentationExportScope = 'all' | 'visible';
|
||||
type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
interface ModuleStyleRecord {
|
||||
visible: boolean;
|
||||
@@ -42,6 +44,13 @@ interface SegmentationResultRecord {
|
||||
segmentationScope: SegmentationExportScope;
|
||||
pose: ModelPoseValue;
|
||||
moduleStyles: Record<string, ModuleStyleRecord>;
|
||||
sliceStart: number;
|
||||
sliceEnd: number;
|
||||
mappingSlice: number;
|
||||
displayLevel: SegmentationDisplayLevel;
|
||||
dicomOpacityLevel: SegmentationDicomOpacityLevel;
|
||||
showBounds: boolean;
|
||||
cutEnabled: boolean;
|
||||
}
|
||||
|
||||
interface UserRecord {
|
||||
@@ -285,30 +294,51 @@ function normalizeSegmentationResults(
|
||||
existing: Partial<SegmentationResultRecord>[] | undefined,
|
||||
stlFiles: string[],
|
||||
currentModuleStyles: Record<string, ModuleStyleRecord>,
|
||||
dicomCount = 0,
|
||||
) {
|
||||
if (!Array.isArray(existing)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxSlice = Math.max(dicomCount - 1, 0);
|
||||
const normalizeSlice = (value: unknown, fallback: number) => (
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
? clampNumber(Math.round(value), 0, maxSlice)
|
||||
: clampNumber(fallback, 0, maxSlice)
|
||||
);
|
||||
const normalizeDisplayLevel = (value: unknown): SegmentationDisplayLevel => (
|
||||
value === 'fine' || value === 'ultra' || value === 'solid' ? value : 'standard'
|
||||
);
|
||||
const normalizeDicomOpacityLevel = (value: unknown): SegmentationDicomOpacityLevel => (
|
||||
value === 'medium' || value === 'high' ? value : 'low'
|
||||
);
|
||||
|
||||
return existing
|
||||
.map((record, index): SegmentationResultRecord => {
|
||||
const rawStyles = record?.moduleStyles && typeof record.moduleStyles === 'object' && !Array.isArray(record.moduleStyles)
|
||||
? record.moduleStyles
|
||||
: currentModuleStyles;
|
||||
const sliceStart = normalizeSlice(record?.sliceStart, 0);
|
||||
const sliceEnd = normalizeSlice(record?.sliceEnd, maxSlice);
|
||||
return {
|
||||
id: typeof record?.id === 'string' && record.id.trim()
|
||||
? record.id.trim().slice(0, 80)
|
||||
: `segmentation-${index}`,
|
||||
name: typeof record?.name === 'string' && record.name.trim()
|
||||
? record.name.trim().slice(0, 80)
|
||||
: `分割结果 ${index + 1}`,
|
||||
name: '逆向分割结果',
|
||||
createdAt: typeof record?.createdAt === 'string' && record.createdAt.trim() ? record.createdAt : now(),
|
||||
segmentationScope: record?.segmentationScope === 'all' ? 'all' : 'visible',
|
||||
pose: normalizeModelPoseValue(record?.pose),
|
||||
moduleStyles: buildModuleStyles(stlFiles, rawStyles),
|
||||
sliceStart,
|
||||
sliceEnd,
|
||||
mappingSlice: normalizeSlice(record?.mappingSlice, sliceEnd),
|
||||
displayLevel: normalizeDisplayLevel(record?.displayLevel),
|
||||
dicomOpacityLevel: normalizeDicomOpacityLevel(record?.dicomOpacityLevel),
|
||||
showBounds: typeof record?.showBounds === 'boolean' ? record.showBounds : true,
|
||||
cutEnabled: typeof record?.cutEnabled === 'boolean' ? record.cutEnabled : false,
|
||||
};
|
||||
})
|
||||
.slice(-20);
|
||||
.slice(-1);
|
||||
}
|
||||
|
||||
function buildDefaultProject(): ProjectRecord {
|
||||
@@ -382,7 +412,7 @@ function normalizeState(state: AppState): AppState {
|
||||
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
|
||||
moduleStyles,
|
||||
modelPoses: normalizeModelPoses(project.modelPoses),
|
||||
segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles),
|
||||
segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, project.dicomCount ?? 0),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -401,6 +431,7 @@ function normalizeState(state: AppState): AppState {
|
||||
savedDefaultProject?.segmentationResults,
|
||||
defaultProject.stlFiles,
|
||||
defaultModuleStyles,
|
||||
defaultProject.dicomCount,
|
||||
),
|
||||
},
|
||||
...customProjects,
|
||||
@@ -1135,6 +1166,22 @@ function parseSegmentationScope(raw: unknown): SegmentationExportScope {
|
||||
return raw === 'all' ? 'all' : 'visible';
|
||||
}
|
||||
|
||||
function latestSegmentationResult(project: ProjectRecord) {
|
||||
return project.segmentationResults?.[project.segmentationResults.length - 1];
|
||||
}
|
||||
|
||||
function projectWithSegmentationResultStyles(project: ProjectRecord): ProjectRecord {
|
||||
const latestResult = latestSegmentationResult(project);
|
||||
if (!latestResult) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
moduleStyles: latestResult.moduleStyles,
|
||||
};
|
||||
}
|
||||
|
||||
function parseExportTargets(raw: unknown): ProjectExportTarget[] {
|
||||
const values = typeof raw === 'string' ? raw.split(',') : [];
|
||||
const targets = values.filter((value): value is ProjectExportTarget => (
|
||||
@@ -2071,17 +2118,26 @@ async function startServer() {
|
||||
: project.moduleStyles;
|
||||
const record: SegmentationResultRecord = {
|
||||
id: `segmentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name: rawName || `分割结果 ${project.segmentationResults.length + 1}`,
|
||||
name: rawName || '逆向分割结果',
|
||||
createdAt: now(),
|
||||
segmentationScope: parseSegmentationScope(req.body?.segmentationScope),
|
||||
pose: normalizeModelPoseValue(req.body?.pose as Partial<ModelPoseValue> | undefined),
|
||||
moduleStyles: buildModuleStyles(project.stlFiles, rawStyles),
|
||||
sliceStart: Number(req.body?.sliceStart),
|
||||
sliceEnd: Number(req.body?.sliceEnd),
|
||||
mappingSlice: Number(req.body?.mappingSlice),
|
||||
displayLevel: req.body?.displayLevel as SegmentationDisplayLevel,
|
||||
dicomOpacityLevel: req.body?.dicomOpacityLevel as SegmentationDicomOpacityLevel,
|
||||
showBounds: typeof req.body?.showBounds === 'boolean' ? req.body.showBounds : true,
|
||||
cutEnabled: typeof req.body?.cutEnabled === 'boolean' ? req.body.cutEnabled : false,
|
||||
};
|
||||
|
||||
project.moduleStyles = record.moduleStyles;
|
||||
project.segmentationResults = normalizeSegmentationResults(
|
||||
[...(project.segmentationResults ?? []), record],
|
||||
[record],
|
||||
project.stlFiles,
|
||||
project.moduleStyles,
|
||||
record.moduleStyles,
|
||||
project.dicomCount,
|
||||
);
|
||||
writeState(state);
|
||||
res.status(201).json(project);
|
||||
@@ -2276,12 +2332,16 @@ async function startServer() {
|
||||
|
||||
const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation');
|
||||
const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation';
|
||||
const activePose = parseModelPoseQuery(req.query.pose);
|
||||
const segmentationScope = parseSegmentationScope(req.query.segmentationScope);
|
||||
const exportProject = projectWithSegmentationResultStyles(project);
|
||||
const latestResult = latestSegmentationResult(project);
|
||||
const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose;
|
||||
const segmentationScope = req.query.segmentationScope === undefined
|
||||
? latestResult?.segmentationScope ?? 'visible'
|
||||
: parseSegmentationScope(req.query.segmentationScope);
|
||||
|
||||
try {
|
||||
if (target === 'pose') {
|
||||
const posePayload = createPoseExport(project, activePose);
|
||||
const posePayload = createPoseExport(exportProject, activePose);
|
||||
const filename = `${project.id}-pose-data.json`;
|
||||
fs.writeFileSync(path.join(exportDir, filename), posePayload);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
@@ -2293,7 +2353,7 @@ async function startServer() {
|
||||
const files = getProjectDicomFiles(project);
|
||||
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
|
||||
const compressed = format === 'nii.gz';
|
||||
const payload = createNiftiExport(project, files, target, compressed, activePose, segmentationScope);
|
||||
const payload = createNiftiExport(exportProject, files, target, compressed, activePose, segmentationScope);
|
||||
const suffix = target === 'dicom' ? 'dicom-image' : 'segmentation-label';
|
||||
const filename = `${project.id}-${suffix}.${format}`;
|
||||
fs.writeFileSync(path.join(exportDir, filename), payload);
|
||||
@@ -2323,15 +2383,19 @@ async function startServer() {
|
||||
return;
|
||||
}
|
||||
|
||||
const activePose = parseModelPoseQuery(req.query.pose);
|
||||
const segmentationScope = parseSegmentationScope(req.query.segmentationScope);
|
||||
const exportProject = projectWithSegmentationResultStyles(project);
|
||||
const latestResult = latestSegmentationResult(project);
|
||||
const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose;
|
||||
const segmentationScope = req.query.segmentationScope === undefined
|
||||
? latestResult?.segmentationScope ?? 'visible'
|
||||
: parseSegmentationScope(req.query.segmentationScope);
|
||||
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
|
||||
const compressed = format === 'nii.gz';
|
||||
|
||||
try {
|
||||
const files = getProjectDicomFiles(project);
|
||||
const payload = createProjectExportBundle({
|
||||
project,
|
||||
project: exportProject,
|
||||
files,
|
||||
targets,
|
||||
compressed,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
默认使用最新保存的分割结果位姿;可选择导出 DICOM、Label 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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
79
工程分析/实现方案-2026-05-20-14-19-23.md
Normal file
79
工程分析/实现方案-2026-05-20-14-19-23.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 实现方案:逆向分割结果单结果化与入口统一
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-14-19-23.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
调整项目库与逆向工作区的逆向分割结果保存、展示、读取和导出交互,使结果只保留一个、命名统一、入口集中、退出有保存提醒。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-05-20-14-19-23.md`
|
||||
- `工程分析/实现方案-2026-05-20-14-19-23.md`
|
||||
- `工程分析/测试方案-2026-05-20-14-19-23.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端保存分割结果接口改为覆盖保存,只保留最新一条记录。
|
||||
2. 项目归一化时保留最新一条 `segmentationResults`,兼容旧状态中已有多条记录。
|
||||
3. 扩展 `SegmentationResult` 存储必要的切片范围、当前映射切片、显示/融合配置等上下文,用于重新进入工作区恢复。
|
||||
4. 逆向工作区加载项目时读取最新保存结果,恢复位姿、构件样式、分割范围和映射切片。
|
||||
5. `App.tsx` 接入工作区离开保护:离开逆向工作区时调用工作区注册的保存确认逻辑。
|
||||
6. 项目库页签改名为“逆向分割结果”,内容改为两张视图预览卡片和右侧导出面板。
|
||||
7. 顶部导出文案统一改为“导出项目及结果”,并将保存按钮移动到旁边。
|
||||
8. 删除映射视图标题旁边的 `NII`、`NII.GZ` 小下载按钮。
|
||||
9. 保存成功后显示顶部悬浮提示并渐隐消失。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 阅读现有类型、API、后端保存接口、项目库和逆向工作区组件结构。
|
||||
- 更新类型定义与后端归一化/保存逻辑。
|
||||
- 更新 API payload 和前端保存调用。
|
||||
- 更新项目库 UI 与导出按钮文案。
|
||||
- 更新逆向工作区顶部操作区和映射视图标题区。
|
||||
- 接入退出保存确认和进入工作区结果恢复。
|
||||
- 运行类型/构建检查、服务部署验证。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 后端归一化兼容旧的多条 `segmentationResults`,只取最后一条作为当前结果。
|
||||
- 若保存上下文字段缺失,前端继续使用默认位姿、默认切片范围和默认显示配置。
|
||||
- 若导出按钮菜单异常,可继续调用现有 `/api/projects/:projectId/export-bundle` 后端接口。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
程序文件:
|
||||
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/server.ts`
|
||||
|
||||
工程分析:
|
||||
|
||||
- 本轮三份分析文档。
|
||||
- `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 暂存本轮程序改动与工程分析文档。
|
||||
- 避免提交软著材料、运行态导出文件、旧历史文档删除状态。
|
||||
- commit message 包含 `2026-05-20-14-19-23`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom` 并验证服务。
|
||||
|
||||
## 实际实现记录
|
||||
|
||||
- 后端 `segmentationResults` 归一化改为只保留最新一条,并扩展保存切片、显示、边界与切割状态字段。
|
||||
- 导出接口默认使用最新保存结果的位姿、构件样式和类别范围。
|
||||
- 逆向工作区进入时恢复最新保存结果,退出时注册保存确认守卫。
|
||||
- 项目库“逆向分割结果”改为两张预览图加右侧统一导出面板。
|
||||
- 顶部保存按钮迁移到导出按钮旁,并加入保存成功悬浮渐隐提示。
|
||||
58
工程分析/测试方案-2026-05-20-14-19-23.md
Normal file
58
工程分析/测试方案-2026-05-20-14-19-23.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 测试方案:逆向分割结果保存与导出 UI 验证
|
||||
|
||||
测试方案文档路径:`工程分析/测试方案-2026-05-20-14-19-23.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 全局搜索确认“导出全部 NII.GZ”文案已替换为“导出项目及结果”。
|
||||
- 全局搜索确认项目库页签“分割结果”改为“逆向分割结果”。
|
||||
- 检查“逆向分割映射视图”标题旁不再存在 `NII`、`NII.GZ` 小下载按钮。
|
||||
- 检查保存接口后端只保留一条结果。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 执行 `npm run build`。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 进入项目库,确认“逆向分割结果”页签存在。
|
||||
- 在逆向工作区顶部确认“保存至项目库”与“导出项目及结果”并列。
|
||||
- 点击保存后,确认顶部出现“已保存至项目库的分割结果区域”悬浮提示并自动消失。
|
||||
- 从逆向工作区切换到其他模块时,确认出现是否保存当前结果的弹窗。
|
||||
- 重新进入逆向工作区,确认读取项目库最新逆向分割结果的位姿、构件样式、切片范围等上下文。
|
||||
- 项目库“逆向分割结果”区域仅展示融合视角和映射视图两张结果预览图,并有导出按钮。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 导出包仍使用现有后端导出能力,位姿和构件样式来自最新保存结果或当前工作区结果。
|
||||
- 不改变 DICOM/STL 原始数据。
|
||||
- 不提交运行态导出文件。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 验证 `http://127.0.0.1:4000/api/health`。
|
||||
- 验证 `http://127.0.0.1:4000/` 返回 200。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- commit message 包含 `2026-05-20-14-19-23`。
|
||||
- 推送 Gitea 成功后记录 commit。
|
||||
- 确认未暂存软著文档、运行态导出文件和历史删除状态。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 覆盖保存会改变旧多条结果历史展示方式,需确认项目库只保留当前结果。
|
||||
- 退出弹窗若在保存失败时仍切换页面,可能丢状态,需处理失败阻断。
|
||||
- 恢复保存结果时需防止不完整旧数据造成 NaN 或空样式。
|
||||
|
||||
## 实际执行记录
|
||||
|
||||
- 已执行 `rg` 静态搜索,确认 `导出全部 NII.GZ` 文案已替换为 `导出项目及结果`。
|
||||
- 已执行 `rg` 静态搜索,确认项目库页签使用 `逆向分割结果`。
|
||||
- 已删除逆向工作区映射视图标题旁的 `NII`、`NII.GZ` 小下载按钮入口。
|
||||
- 已执行 `npm run lint`,结果通过。
|
||||
- 已执行 `npm run build`,结果通过;仅保留 Vite 大 chunk 体积提示。
|
||||
- 已重启 `tmux` 会话 `revoxelseg-dicom`,服务监听 `http://0.0.0.0:4000/`。
|
||||
- 已验证 `http://127.0.0.1:4000/api/health` 返回 `ok: true`。
|
||||
- 已验证 `http://127.0.0.1:4000/` 返回 HTTP 200。
|
||||
- 已验证默认项目接口中 `segmentationResults` 数量为 1,最新结果名称为 `逆向分割结果`。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1243,3 +1243,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
软著截图默认采用完整页面截图;只有用户明确要求“局部图”“放大细节图”时才裁剪。若同一完整页面用于多个章节,校验 Word 时应统计正文图片位置数量,而不能只看 `word/media` 文件数,因为 Word 会复用相同图片二进制。
|
||||
|
||||
## 2026-05-20-14-19-23 逆向分割结果单结果化需要前后端同时收口
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求项目库“分割结果”改为“逆向分割结果”,并且只保留一个当前结果,同时逆向工作区退出时询问是否保存、再次进入时读取项目库结果。如果只改前端展示,后端仍保存多条记录,导出和重新进入工作区会继续使用不一致的状态。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
原有设计把 `segmentationResults` 作为历史列表保存,前端项目库按列表展示,后端导出接口默认使用当前请求参数或项目样式。新的业务语义改成项目级唯一当前结果,涉及保存接口、状态归一化、导出默认参数、工作区加载恢复和视图切换守卫多个链路。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
后端归一化和保存接口均改为只保留最新一条逆向分割结果,并把位姿、构件样式、切片范围、映射切片和显示状态一起保存;导出接口默认读取最新保存结果。前端项目库改为两张结果预览图和统一导出面板;逆向工作区顶部集中保存与导出按钮,保存后显示渐隐提示;主应用视图切换时调用工作区离开守卫,保存失败时阻断退出。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
凡是把“历史记录”改为“当前唯一状态”的需求,都要同步检查数据模型、归一化逻辑、保存接口、列表 UI、导出默认参数和重新进入页面的恢复逻辑。提交前用 `rg` 搜索旧文案和旧入口,避免残留重复下载按钮或旧命名造成用户误解。
|
||||
|
||||
67
工程分析/需求分析-2026-05-20-14-19-23.md
Normal file
67
工程分析/需求分析-2026-05-20-14-19-23.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 需求分析:逆向分割结果保存、展示与导出入口调整
|
||||
|
||||
开始时间:`2026-05-20-14-19-23`
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求修改程序:
|
||||
|
||||
1. 项目库中“分割结果”改名为“逆向分割结果”,该区域只显示“影像与模型融合视角”和“逆向分割映射视图”两张图;右侧也提供“导出全部 NII.GZ”同等功能按钮。
|
||||
2. 逆向工作区中“保存至项目库”按钮移动到顶部“导出全部 NII.GZ”按钮旁边,按钮样式相近。
|
||||
3. 项目库分割结果只保留一个;从逆向工作区退出时弹窗询问是否保存当前结果;每次进入逆向工作区读取项目库中“逆向分割结果”的结果。
|
||||
4. 删除“逆向分割映射视图”旁边的 `NII`、`NII.GZ` 下载按钮。
|
||||
5. 全部“导出全部 NII.GZ”文案改为“导出项目及结果”。
|
||||
6. 点击保存至项目库后,在上方悬浮显示“已保存至项目库的分割结果区域”,并渐隐消失。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 将“逆向分割结果”从多条历史记录改为项目级单一当前结果,便于用户明确当前项目最终分割状态。
|
||||
- 统一导出入口,避免右侧映射视图附近存在重复的 NII/NII.GZ 小按钮。
|
||||
- 在项目库中以两张关键视图预览保存状态,强化“融合视角 + 映射视图”的复核语义。
|
||||
- 保证工作区退出和重新进入时,用户的位姿、构件样式、切片范围等关键上下文能够延续。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
输入:
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/server.ts`
|
||||
|
||||
输出:
|
||||
|
||||
- 项目库页签和内容改名为“逆向分割结果”。
|
||||
- 逆向工作区顶部新增/迁移“保存至项目库”按钮。
|
||||
- 删除映射视图标题旁边的 `NII`、`NII.GZ` 下载按钮。
|
||||
- 导出按钮文案统一为“导出项目及结果”。
|
||||
- 保存结果只保留单条,并支持退出弹窗保存与进入工作区读取。
|
||||
- 保存后显示顶部悬浮渐隐提示。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 前端主框架、项目库和逆向工作区交互。
|
||||
- 后端项目状态归一化与分割结果保存接口。
|
||||
- 项目导出包入口复用现有后端导出逻辑。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 保留现有导出包功能和参数能力,不破坏 DICOM、分割影像、位姿数据、STL 原始模型的可选导出。
|
||||
- 只保留最新一个逆向分割结果,旧多条结果列表不再展示。
|
||||
- 退出逆向工作区时不能直接丢弃当前工作状态,需要询问是否保存。
|
||||
- 重新进入逆向工作区时应读取项目库中保存的位姿、构件样式和分割范围。
|
||||
- UI 风格贴合现有医疗工具风格,避免过度装饰。
|
||||
|
||||
## 风险点
|
||||
|
||||
- `App.tsx` 当前直接切换工作区,退出工作区弹窗需要在视图切换链路中接入。
|
||||
- 保存结果如果写入多个历史记录,会与“只保留一个”要求冲突,需要前后端一致改为覆盖。
|
||||
- 若前端只恢复位姿而不恢复构件样式,项目库中的逆向分割结果与重新进入工作区显示会不一致。
|
||||
- 当前工作区存在历史工程分析文档删除状态和软著未跟踪目录,提交时需精确暂存。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- “两个图”优先采用当前项目库中的可视化预览卡片表达:影像与模型融合视角、逆向分割映射视图。若没有保存结果则显示空状态。
|
||||
- 本轮不引入真实截图持久化文件,先以保存上下文驱动两张结果预览图和导出入口;避免在运行态中持久化大体积 base64 图片。
|
||||
Reference in New Issue
Block a user