From 2a599695e97a776d605239f83cec950f371bec96 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 20 May 2026 14:38:01 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-20-14-19-23=20=E9=80=86=E5=90=91?= =?UTF-8?q?=E5=88=86=E5=89=B2=E7=BB=93=E6=9E=9C=E6=B5=81=E7=A8=8B=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 94 ++++++++-- WebSite/src/App.tsx | 49 +++++- WebSite/src/components/ProjectLibrary.tsx | 176 ++++++++++++++----- WebSite/src/components/ReverseWorkspace.tsx | 181 +++++++++++++------- WebSite/src/lib/api.ts | 9 +- WebSite/src/types.ts | 9 + 工程分析/实现方案-2026-05-20-14-19-23.md | 79 +++++++++ 工程分析/测试方案-2026-05-20-14-19-23.md | 58 +++++++ 工程分析/经验记录.md | 18 ++ 工程分析/需求分析-2026-05-20-14-19-23.md | 67 ++++++++ 10 files changed, 616 insertions(+), 124 deletions(-) create mode 100644 工程分析/实现方案-2026-05-20-14-19-23.md create mode 100644 工程分析/测试方案-2026-05-20-14-19-23.md create mode 100644 工程分析/需求分析-2026-05-20-14-19-23.md diff --git a/WebSite/server.ts b/WebSite/server.ts index 198d6e3..20eb11e 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -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; + sliceStart: number; + sliceEnd: number; + mappingSlice: number; + displayLevel: SegmentationDisplayLevel; + dicomOpacityLevel: SegmentationDicomOpacityLevel; + showBounds: boolean; + cutEnabled: boolean; } interface UserRecord { @@ -285,30 +294,51 @@ function normalizeSegmentationResults( existing: Partial[] | undefined, stlFiles: string[], currentModuleStyles: Record, + 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 | 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, diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index bc62e14..2e537cc 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -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.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) | 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() {
} {activeView === ViewType.PROJECTS && ( { setActiveProjectId(projectId); setActiveView(ViewType.WORKSPACE); }} /> )} - {activeView === ViewType.WORKSPACE && } + {activeView === ViewType.WORKSPACE && ( + { + workspaceLeaveGuardRef.current = handler; + }} + /> + )} {activeView === ViewType.SYSTEM && } diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index aeada2c..a758250 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -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(defaultModelPose); const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); + const [resultDicomPreview, setResultDicomPreview] = useState(null); const [dicomInfo, setDicomInfo] = useState(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>>(() => () => undefined, []); const makeDefaultModuleStyle = (index: number, fallback?: Partial): 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 = {}; 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' && ( -
-
-
-
-

分割结果

-

- 来自逆向工作区保存的 Label Map 复核记录 +

+
+
+
+

影像与模型融合视角

+

+ {latestSegmentationResult ? `Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'}

- - {savedSegmentationResults.length} 条 - + {latestSegmentationResult ? ( + + ) : ( +
+ 暂无保存结果,请在逆向工作区保存当前映射。 +
+ )}
- {savedSegmentationResults.length ? ( -
- {savedSegmentationResults.map((result, index) => ( -
-
-

{result.name}

- - #{index + 1} - -
-
- 范围:{result.segmentationScope === 'all' ? '所有类别' : '可见类别'} - {new Date(result.createdAt).toLocaleString('zh-CN', { hour12: false })} - RX {result.pose.rotateX.toFixed(0)}° - TZ {result.pose.translateZ.toFixed(3)} +
+
+ + BASE DICOM + + + OVERLAY LABEL MAP + + + Z {resultMappingSlice + 1}/{selectedProject.dicomCount} + +
+
+ {latestSegmentationResult && resultDicomPreview ? ( +
+ +
+
+ 当前切片构件 + {resultVisibleModules.length} 个构件 +
+
+ {resultVisibleModules.slice(0, 8).map(({ fileName, name, style }) => ( +
+ + {name} + ID {style.partId} +
+ ))} +
- ))} + ) : ( +

+ {latestSegmentationResult ? '正在载入逆向分割映射视图...' : '暂无逆向分割映射视图'} +

+ )}
- ) : ( -
- 暂无保存的分割结果,请在逆向工作区点击“保存至项目库”。 -
- )} +
-

导出分割包

-

- 默认使用最新保存的分割结果位姿;可选择导出 DICOM、Label Map、位姿数据与原始 STL。 -

+
+
+

逆向分割结果

+

+ 项目库仅保留最新一次保存结果,导出时默认沿用该结果的位姿、构件样式与类别范围。 +

+
+ + {latestSegmentationResult ? '已保存' : '未保存'} + +
+
+ 切片:{selectedProject.dicomCount ? `${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '--'} + 类别:{latestSegmentationResult?.segmentationScope === 'all' ? '所有类别' : '可见类别'} + 构件:{resultVisibleModules.length} + + {latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'} + +
+
{showMaskExportMenu && (
@@ -1619,12 +1707,6 @@ export default function ProjectLibrary({
)}
-
)} diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 4724afd..ac93717 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -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; +type WorkspaceLeaveGuard = () => Promise; 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()); const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); const poseImportInputRef = useRef(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(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 => ({ 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 = {}; (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 (
+ {saveStatus && ( + <> + +
+ {saveStatus} +
+ + )}
{project && ( @@ -2348,6 +2436,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { {!project &&

配准 DICOM 影像与三维模型,生成像素映射关系

}
+
{showExportMenu && (
@@ -2799,38 +2895,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { 逆向分割映射视图 -
- - - -
- {saveStatus && ( -
- {saveStatus} -
- )} ; + sliceStart?: number; + sliceEnd?: number; + mappingSlice?: number; + displayLevel?: SegmentationDisplayLevel; + dicomOpacityLevel?: SegmentationDicomOpacityLevel; + showBounds?: boolean; + cutEnabled?: boolean; }, ) => request(`/api/projects/${projectId}/segmentation-results`, { diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index dd7a80e..da87ed8 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -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; + sliceStart?: number; + sliceEnd?: number; + mappingSlice?: number; + displayLevel?: SegmentationDisplayLevel; + dicomOpacityLevel?: SegmentationDicomOpacityLevel; + showBounds?: boolean; + cutEnabled?: boolean; } export interface MaskMapping { diff --git a/工程分析/实现方案-2026-05-20-14-19-23.md b/工程分析/实现方案-2026-05-20-14-19-23.md new file mode 100644 index 0000000..eed43bd --- /dev/null +++ b/工程分析/实现方案-2026-05-20-14-19-23.md @@ -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` 归一化改为只保留最新一条,并扩展保存切片、显示、边界与切割状态字段。 +- 导出接口默认使用最新保存结果的位姿、构件样式和类别范围。 +- 逆向工作区进入时恢复最新保存结果,退出时注册保存确认守卫。 +- 项目库“逆向分割结果”改为两张预览图加右侧统一导出面板。 +- 顶部保存按钮迁移到导出按钮旁,并加入保存成功悬浮渐隐提示。 diff --git a/工程分析/测试方案-2026-05-20-14-19-23.md b/工程分析/测试方案-2026-05-20-14-19-23.md new file mode 100644 index 0000000..829bc59 --- /dev/null +++ b/工程分析/测试方案-2026-05-20-14-19-23.md @@ -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,最新结果名称为 `逆向分割结果`。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 0d1db55..f84b8d0 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1243,3 +1243,21 @@ C. 解决问题方案 D. 后续如何避免问题 软著截图默认采用完整页面截图;只有用户明确要求“局部图”“放大细节图”时才裁剪。若同一完整页面用于多个章节,校验 Word 时应统计正文图片位置数量,而不能只看 `word/media` 文件数,因为 Word 会复用相同图片二进制。 + +## 2026-05-20-14-19-23 逆向分割结果单结果化需要前后端同时收口 + +A. 具体问题 + +用户要求项目库“分割结果”改为“逆向分割结果”,并且只保留一个当前结果,同时逆向工作区退出时询问是否保存、再次进入时读取项目库结果。如果只改前端展示,后端仍保存多条记录,导出和重新进入工作区会继续使用不一致的状态。 + +B. 产生问题原因 + +原有设计把 `segmentationResults` 作为历史列表保存,前端项目库按列表展示,后端导出接口默认使用当前请求参数或项目样式。新的业务语义改成项目级唯一当前结果,涉及保存接口、状态归一化、导出默认参数、工作区加载恢复和视图切换守卫多个链路。 + +C. 解决问题方案 + +后端归一化和保存接口均改为只保留最新一条逆向分割结果,并把位姿、构件样式、切片范围、映射切片和显示状态一起保存;导出接口默认读取最新保存结果。前端项目库改为两张结果预览图和统一导出面板;逆向工作区顶部集中保存与导出按钮,保存后显示渐隐提示;主应用视图切换时调用工作区离开守卫,保存失败时阻断退出。 + +D. 后续如何避免问题 + +凡是把“历史记录”改为“当前唯一状态”的需求,都要同步检查数据模型、归一化逻辑、保存接口、列表 UI、导出默认参数和重新进入页面的恢复逻辑。提交前用 `rg` 搜索旧文案和旧入口,避免残留重复下载按钮或旧命名造成用户误解。 diff --git a/工程分析/需求分析-2026-05-20-14-19-23.md b/工程分析/需求分析-2026-05-20-14-19-23.md new file mode 100644 index 0000000..b2c404a --- /dev/null +++ b/工程分析/需求分析-2026-05-20-14-19-23.md @@ -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 图片。