From ec4cb1eae71545d708847b9a0a4108e17ab05f25 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 20 May 2026 22:19:02 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-20-22-07-46=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E4=B8=8E=E6=98=A0=E5=B0=84=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=91=98=E8=A6=81=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 39 ++++- WebSite/src/components/ProjectLibrary.tsx | 37 ++--- WebSite/src/components/ReverseWorkspace.tsx | 166 ++++++++++++-------- 工程分析/实现方案-2026-05-20-22-07-46.md | 57 +++++++ 工程分析/测试方案-2026-05-20-22-07-46.md | 56 +++++++ 工程分析/经验记录.md | 18 +++ 工程分析/需求分析-2026-05-20-22-07-46.md | 50 ++++++ 7 files changed, 336 insertions(+), 87 deletions(-) create mode 100644 工程分析/实现方案-2026-05-20-22-07-46.md create mode 100644 工程分析/测试方案-2026-05-20-22-07-46.md create mode 100644 工程分析/需求分析-2026-05-20-22-07-46.md diff --git a/WebSite/server.ts b/WebSite/server.ts index 3c2a834..d223fdd 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -159,6 +159,36 @@ function now() { return new Date().toISOString(); } +function timestampForFilename(date = new Date()) { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(date); + const value = (type: string) => parts.find((part) => part.type === type)?.value ?? '00'; + return `${value('year')}-${value('month')}-${value('day')}-${value('hour')}-${value('minute')}-${value('second')}`; +} + +function sanitizeFilenamePart(input: string, fallback: string) { + const cleaned = input + .trim() + .replace(/[\\/:*?"<>|]+/g, '_') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + return cleaned || fallback; +} + +function contentDispositionAttachment(filename: string) { + const asciiFallback = filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); + return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`; +} + function ensureDir(dir: string) { fs.mkdirSync(dir, { recursive: true }); } @@ -1247,6 +1277,7 @@ function createProjectExportBundle({ compressed, activePose, segmentationScope, + exportRoot, }: { project: ProjectRecord; files: string[]; @@ -1254,12 +1285,12 @@ function createProjectExportBundle({ compressed: boolean; activePose?: ModelPoseValue; segmentationScope: SegmentationExportScope; + exportRoot: string; }) { const entries: Array<{ name: string; data: Buffer; mtime?: number }> = []; const needsVolume = targets.includes('dicom') || targets.includes('segmentation'); const volume = needsVolume ? readDicomHuVolume(files) : null; const format = compressed ? 'nii.gz' : 'nii'; - const exportRoot = `${project.id}-nifti-export`; if (targets.includes('dicom') && volume) { entries.push({ @@ -2488,6 +2519,7 @@ async function startServer() { try { const files = getProjectDicomFiles(project); + const exportBase = `${sanitizeFilenamePart(project.name, project.id)}_${timestampForFilename()}`; const payload = createProjectExportBundle({ project: exportProject, files, @@ -2495,14 +2527,15 @@ async function startServer() { compressed, activePose, segmentationScope, + exportRoot: exportBase, }); - const filename = `${project.id}-nifti-export.tar.gz`; + const filename = `${exportBase}.tar.gz`; fs.writeFileSync(path.join(exportDir, filename), payload); project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0; writeState(state); res.setHeader('Content-Type', 'application/gzip'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Disposition', contentDispositionAttachment(filename)); res.send(payload); } catch (error) { res.status(422).json({ message: error instanceof Error ? error.message : '导出包生成失败' }); diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 04b7e5f..80eef18 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -113,6 +113,10 @@ function clampModelPose(next: ModelPose): ModelPose { }; } +function formatPoseCompactValue(value: number, digits = 2) { + return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0'; +} + function drawFallbackModelPreview( canvas: HTMLCanvasElement, previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>, @@ -759,19 +763,6 @@ export default function ProjectLibrary({ const resultDicomOpacity = reverseDicomOpacityOptions.find((option) => option.id === 'high') ?? reverseDicomOpacityOptions[reverseDicomOpacityOptions.length - 1]; const resultCutStart = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceStart ?? 0)); const resultCutEnd = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceEnd ?? 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 makeDefaultModuleStyle = (index: number, fallback?: Partial): ModuleStyle => ({ visible: fallback?.visible ?? true, color: fallback?.color ?? defaultModuleColors[index % defaultModuleColors.length], @@ -1646,7 +1637,7 @@ export default function ProjectLibrary({

逆向分割结果

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

@@ -1654,13 +1645,23 @@ export default function ProjectLibrary({
- 切片:{selectedProject.dicomCount ? `${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '--'} - 类别:{latestSegmentationResult?.segmentationScope === 'all' ? '所有类别' : '可见类别'} - 构件:{resultVisibleModules.length} + 构件总数:{selectedProject.modelCount ?? stlFiles.length} - {latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'} + 最后保存:{latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'}
+
+

模型位姿

+
+ RX {formatPoseCompactValue(latestResultPose.rotateX, 1)}° + RY {formatPoseCompactValue(latestResultPose.rotateY, 1)}° + RZ {formatPoseCompactValue(latestResultPose.rotateZ, 1)}° + TX {formatPoseCompactValue(latestResultPose.translateX, 3)} + TY {formatPoseCompactValue(latestResultPose.translateY, 3)} + TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)} + Scale {formatPoseCompactValue(latestResultPose.scale, 3)} +
+
diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 00f6968..25d2487 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -9,7 +9,6 @@ import { ChevronDown, ChevronUp, Eye, - Layers, Maximize2, RefreshCcw, Save, @@ -1867,6 +1866,7 @@ export function VoxelizationMappingView({ rotation, variant = 'workspace', toolbar, + overlayPlacement, }: { project: Project | null; moduleStyles: Record; @@ -1879,6 +1879,7 @@ export function VoxelizationMappingView({ rotation: number; variant?: 'workspace' | 'library'; toolbar?: React.ReactNode; + overlayPlacement?: 'bottom' | 'side'; }) { const baseCanvasRef = useRef(null); const overlayCanvasRef = useRef(null); @@ -1901,6 +1902,7 @@ export function VoxelizationMappingView({ const stlFiles = project?.stlFiles ?? []; const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length; const isLibraryVariant = variant === 'library'; + const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom'); useEffect(() => { if (!project?.dicomCount) { @@ -2051,6 +2053,35 @@ export function VoxelizationMappingView({ event.currentTarget.releasePointerCapture(event.pointerId); } }; + const renderOverlaySummary = (placement: 'bottom' | 'side') => ( +
+
+ Overlay Label Map · {overlayStatus} + + {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px + +
+
+ {overlayStats.modules.length ? ( +
+ {overlayStats.modules.map((item) => ( +
+ + {item.name} + ID {item.partId} + {item.segmentCount} 边 + {item.filledPixels} px +
+ ))} +
+ ) : ( +
+ 当前切片暂无可见构件 +
+ )} +
+
+ ); if (isLibraryVariant) { return ( @@ -2072,41 +2103,46 @@ export function VoxelizationMappingView({
-
+
- {dicomPreview ? ( -
- - +
+ {dicomPreview ? ( +
+ + +
+ ) : ( +
+ {dicomStatus} +
+ )} +
+

DICOM 切片位置

+

+ {safeSlice + 1} / {Math.max(totalSlices, 1)} +

- ) : ( -
- {dicomStatus} -
- )} -
-

DICOM 切片位置

-

- {safeSlice + 1} / {Math.max(totalSlices, 1)} -

+ {activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')}
-
@@ -3540,42 +3577,6 @@ export default function ReverseWorkspace({
-
-

- - 逆向分割映射视图 -

-
-
- {mappingDisplayModes.map((mode) => ( - - ))} -
- - -
-
-
+
+ {mappingDisplayModes.map((mode) => ( + + ))} +
+ + + + )} />
diff --git a/工程分析/实现方案-2026-05-20-22-07-46.md b/工程分析/实现方案-2026-05-20-22-07-46.md new file mode 100644 index 0000000..b7b33a7 --- /dev/null +++ b/工程分析/实现方案-2026-05-20-22-07-46.md @@ -0,0 +1,57 @@ +# 实现方案-2026-05-20-22-07-46 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-20-22-07-46.md` + +## 修改目标 + +1. 后端导出压缩包文件名改成 `项目名_时间.tar.gz`,兼容中文项目名。 +2. `VoxelizationMappingView` 支持把 Overlay Label Map 摘要放到右侧下方或底部下方。 +3. 项目库使用右侧 Overlay 摘要布局;逆向工作区使用项目库风格的黑底工具行和右侧切片导航,但 Overlay 仍在下方。 +4. 项目库“逆向分割结果”摘要只显示构件总数、最后保存时间和模型位姿。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/index.css` +- `工程分析/经验记录.md` + +## 技术路线 + +- 在后端新增项目名/时间文件名格式化函数,并用于 bundle 下载响应头。 +- 在 `VoxelizationMappingView` 中抽出 Overlay 摘要渲染片段,通过 `variant` 与布局参数区分项目库和逆向工作区。 +- 逆向工作区把窗宽、旋转和位置重置按钮传入映射视图内部,外层只保留模块标题或直接交给组件渲染。 +- 项目库结果摘要卡片移除切片和类别范围字段,增加位姿字段的紧凑网格。 + +## 执行步骤 + +1. 定位 `server.ts` 导出 bundle 的文件名生成逻辑。 +2. 定位 `ProjectLibrary.tsx` 结果摘要和映射视图调用逻辑。 +3. 调整 `ReverseWorkspace.tsx` 的映射视图 `variant`、工具栏和 Overlay 位置。 +4. 更新样式文件中的暗色竖向滑块或新增必要类名。 +5. 执行 `npm run lint`、`npm run build`。 +6. 重启 `tmux` 服务并验证 `/api/health` 与页面响应。 +7. 追加经验记录,提交并推送到 Gitea。 + +## 兼容性与回滚方案 + +- 导出文件名只改响应头,不改变压缩包内部结构,回滚时恢复旧 `filename` 即可。 +- 视图布局通过组件参数控制,若项目库右侧摘要不合适,可回退为下方摘要或隐藏摘要。 +- 逆向工作区仍使用同一 `VoxelizationMappingView`,回滚风险集中在一个组件。 + +## 预计文件变更 + +- 修改 `server.ts` 导出文件名。 +- 修改 `ReverseWorkspace.tsx` 和 `ProjectLibrary.tsx` 视图布局与摘要字段。 +- 视需要修改 `index.css` 滑块样式。 +- 新增三份工程分析文档,追加 `经验记录.md`。 + +## 提交与部署策略 + +- 只暂存本次相关源码和本次工程分析文档。 +- commit message 使用 `2026-05-20-22-07-46 导出命名与映射视图摘要优化`。 +- 推送到 Gitea `main` 分支。 +- 构建后重启 `tmux` 会话 `revoxelseg-dicom`。 diff --git a/工程分析/测试方案-2026-05-20-22-07-46.md b/工程分析/测试方案-2026-05-20-22-07-46.md new file mode 100644 index 0000000..e2c85a1 --- /dev/null +++ b/工程分析/测试方案-2026-05-20-22-07-46.md @@ -0,0 +1,56 @@ +# 测试方案-2026-05-20-22-07-46 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-20-22-07-46.md` + +## 静态检查 + +- 在 `WebSite/` 执行 `npm run lint`,验证 TypeScript 类型检查。 + +## 构建检查 + +- 在 `WebSite/` 执行 `npm run build`,验证生产构建。 + +## 关键业务场景验证 + +- 点击“导出项目及结果”时,响应文件名应包含项目名和时间。 +- 项目库“逆向分割映射视图”右侧下方显示 Overlay Label Map 摘要。 +- 逆向工作区映射视图采用类似项目库的黑底工具行和右侧竖向导航。 +- 逆向工作区 Overlay Label Map 信息仍在视图下方,不遮挡 DICOM 影像。 +- 项目库“逆向分割结果”摘要只显示构件总数、最后保存时间、模型位姿。 + +## 医学影像数据相关边界验证 + +- 切片滑动后,DICOM 底图与 Overlay 仍同步更新。 +- Overlay 统计在无当前构件时显示空状态。 +- 模型位姿摘要中的旋转、平移、缩放与保存结果一致。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证: + - `http://127.0.0.1:4000/api/health` + - `http://127.0.0.1:4000/` + +## Git/Gitea 备份验证 + +- `git status --short` 检查只暂存本次相关文件。 +- commit message 包含 `2026-05-20-22-07-46` 与简要描述。 +- 推送 Gitea 后确认远端更新。 + +## 风险与回归关注点 + +- 中文项目名导出文件名在不同浏览器中的兼容性。 +- 项目库右侧 Overlay 摘要不能导致影像主画布过窄。 +- 逆向工作区外层和内层工具栏不能重复出现。 + +## 执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过,仅保留 Vite 大 chunk 既有提示。 +- 重新部署:已重启 `tmux` 会话 `revoxelseg-dicom`,服务监听 `0.0.0.0:4000`。 +- `curl -fsS http://127.0.0.1:4000/api/health`:通过,返回 `ok: true`。 +- `curl -I -fsS http://127.0.0.1:4000/`:通过,返回 `HTTP/1.1 200 OK`。 +- 导出文件名抽查:`/api/projects/head-ct-demo/export-bundle?targets=pose&format=nii.gz` 的 `Content-Disposition` 返回 `filename*=UTF-8''项目名_时间.tar.gz` 形式,例如 `头部_CT_模型逆向体素化演示_2026-05-20-22-17-27.tar.gz`。 +- `git diff --check`:通过。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index d3a7642..b927f8a 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1351,3 +1351,21 @@ C. 解决问题方案 D. 后续如何避免问题 同一个医学视图组件被复用到“编辑工作区”和“项目库复核”时,应优先通过 `variant` 或视角预设区分展示密度,而不是复制第二套近似实现。大体积医学预览数据应使用按项目、切片、窗宽、文件和采样精度组成的缓存 key,避免跨项目串数据;加载页只阻塞首次必要数据,不把普通浏览视口变化误判为需要重新进入全屏加载。 + +## 2026-05-20-22-07-46 导出命名与 Overlay 摘要位置要兼顾浏览器和视图语义 + +A. 具体问题 + +用户要求“导出项目及结果”的文件名改为“项目名_时间”,同时项目库与逆向工作区的逆向分割映射视图要共享黑底调控语言,但 Overlay Label Map 在项目库放右侧下方、在工作区保留在下方。 + +B. 产生问题原因 + +导出接口原先使用项目 ID 生成 `head-ct-demo-nifti-export.tar.gz`,便于程序处理但不便于用户归档。映射视图此前只区分工作区和项目库两种整体 `variant`,没有进一步区分 Overlay 摘要在右侧还是底部,导致布局需求变化时容易复制组件或堆叠条件。 + +C. 解决问题方案 + +后端新增项目名清洗、Asia/Shanghai 时间戳和 RFC 5987 `filename*` 响应头,导出包命名为 `项目名_时间.tar.gz`。前端在 `VoxelizationMappingView` 中增加 `overlayPlacement`,项目库使用 `side` 将 Overlay 摘要放到右侧下方,逆向工作区使用同一黑底工具行但传入 `bottom`,使 Overlay 保持在影像下方。项目库结果摘要移除切片和类别范围,仅保留构件总数、最后保存时间和模型位姿。 + +D. 后续如何避免问题 + +涉及中文下载名时应同时设置 ASCII fallback 和 `filename*`,并用 `curl -D -` 抽查响应头。复用医学影像组件时,布局差异应继续沉到小粒度参数,例如 `overlayPlacement`、`viewPreset`,避免为了位置差异复制整套视图;同时确认 Overlay 摘要不遮挡 DICOM 主画布。 diff --git a/工程分析/需求分析-2026-05-20-22-07-46.md b/工程分析/需求分析-2026-05-20-22-07-46.md new file mode 100644 index 0000000..44d0716 --- /dev/null +++ b/工程分析/需求分析-2026-05-20-22-07-46.md @@ -0,0 +1,50 @@ +# 需求分析-2026-05-20-22-07-46 + +## 开始时间 + +2026-05-20-22-07-46 + +## 原始需求摘要 + +用户要求优化导出与逆向结果界面:导出项目及结果的文件名改为“项目名_时间”;项目库逆向分割映射视图中的 Overlay Label Map 信息放到右侧下方;逆向工作区中的映射视图和调控参考项目库逆向分割映射视图,Overlay Label Map 仍保留在下方;项目库逆向分割结果摘要不再显示切片与可见类别,只显示构件总数、最后保存时间、模型位姿。 + +## 业务目标 + +- 让导出的压缩包文件名可读、可追溯,便于医生或工程师按项目和时间归档。 +- 让项目库结果复核页更紧凑,右侧集中承载切片导航与 Overlay Label Map 摘要。 +- 让逆向工作区和项目库的映射视图交互语言保持一致,减少用户在两个模块之间切换时的认知差异。 +- 将项目库结果摘要聚焦到项目级结果信息,而不是重复显示切片和类别范围。 + +## 输入与输出 + +- 输入:项目名称、当前导出时间、最新逆向分割结果、构件样式、模型位姿、Overlay 统计信息。 +- 输出:新的下载文件名、项目库右侧 Overlay 摘要、逆向工作区黑底映射视图调控布局、精简的逆向分割结果摘要卡片。 + +## 影响范围 + +- `WebSite/server.ts` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/index.css` +- `工程分析/经验记录.md` + +## 关键约束 + +- 文件名需要去除或替换不适合文件系统和 HTTP Header 的特殊字符。 +- Overlay Label Map 统计不应遮挡 DICOM 主画布。 +- 逆向工作区的映射视图要保留下方 Overlay 信息,项目库则放在右侧下方。 +- 位姿摘要要清晰展示旋转、平移、缩放,不能占用过多空间。 +- 提交时不能混入当前工作区既有的历史删除和软著资料。 + +## 风险点 + +- 修改导出 Content-Disposition 时如果中文文件名处理不当,浏览器下载名可能乱码。 +- 项目库右侧栏增加 Overlay 信息后,如果宽度控制不好可能挤压 DICOM 画布。 +- 逆向工作区控件迁移到组件内部后,需要避免外层标题重复或按钮丢失。 +- 结果摘要若直接显示全部位姿字段,可能在窄屏换行混乱。 + +## 默认假设 + +- “项目名_时间”中的时间使用服务器当前时间,格式为 `YYYY-MM-DD-HH-mm-ss`。 +- “导出项目及结果”主要对应 `/export-bundle` 压缩包接口;单独旧导出接口可同步优化但非首要。 +- 项目库 Overlay 放右侧下方,逆向工作区 Overlay 继续放主画布下方。