2026-05-20-22-07-46 导出命名与映射视图摘要优化

This commit is contained in:
2026-05-20 22:19:02 +08:00
parent cc137437bc
commit ec4cb1eae7
7 changed files with 336 additions and 87 deletions

View File

@@ -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 : '导出包生成失败' });

View File

@@ -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>): ModuleStyle => ({
visible: fallback?.visible ?? true,
color: fallback?.color ?? defaultModuleColors[index % defaultModuleColors.length],
@@ -1646,7 +1637,7 @@ export default function ProjectLibrary({
<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'}`}>
@@ -1654,13 +1645,23 @@ export default function ProjectLibrary({
</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">{selectedProject.modelCount ?? stlFiles.length}</span>
<span className="rounded-lg bg-white px-2 py-2">
{latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'}
{latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'}
</span>
</div>
<div className="mt-3 rounded-xl bg-white p-3">
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">姿</p>
<div className="grid grid-cols-3 gap-1.5 text-[9px] font-bold text-slate-500">
<span className="rounded-lg bg-slate-50 px-2 py-1.5">RX {formatPoseCompactValue(latestResultPose.rotateX, 1)}°</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">RY {formatPoseCompactValue(latestResultPose.rotateY, 1)}°</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">RZ {formatPoseCompactValue(latestResultPose.rotateZ, 1)}°</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TX {formatPoseCompactValue(latestResultPose.translateX, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TY {formatPoseCompactValue(latestResultPose.translateY, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)}</span>
<span className="col-span-3 rounded-lg bg-slate-50 px-2 py-1.5">Scale {formatPoseCompactValue(latestResultPose.scale, 3)}</span>
</div>
</div>
</div>
<div className="relative">

View File

@@ -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<string, ModuleStyle>;
@@ -1879,6 +1879,7 @@ export function VoxelizationMappingView({
rotation: number;
variant?: 'workspace' | 'library';
toolbar?: React.ReactNode;
overlayPlacement?: 'bottom' | 'side';
}) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(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') => (
<div className={`${placement === 'side' ? 'w-full rounded-2xl border border-white/10 bg-black/40 p-2' : 'border-t border-white/10 bg-[#030712] px-4 py-3'}`}>
<div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}>
<span className="truncate">Overlay Label Map · {overlayStatus}</span>
<span className="font-mono text-cyan-100">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px
</span>
</div>
<div className={`${placement === 'side' ? 'max-h-44' : 'max-h-24'} overflow-auto pr-1`}>
{overlayStats.modules.length ? (
<div className={`grid gap-1.5 ${placement === 'side' ? 'grid-cols-1' : 'grid-cols-2 xl:grid-cols-3'}`}>
{overlayStats.modules.map((item) => (
<div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-[8px] font-bold text-white/65">
<span className="h-2 w-2 rounded-sm border border-white/30" style={{ backgroundColor: item.color, opacity: item.opacity }} />
<span className="min-w-0 truncate">{item.name}</span>
<span className="font-mono text-cyan-100">ID {item.partId}</span>
<span className="col-start-2 font-mono text-white/35">{item.segmentCount} </span>
<span className="font-mono text-white/35">{item.filledPixels} px</span>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-white/10 bg-white/5 px-2 py-1.5 text-[9px] font-bold text-white/35">
</div>
)}
</div>
</div>
);
if (isLibraryVariant) {
return (
@@ -2072,41 +2103,46 @@ export function VoxelizationMappingView({
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_56px] bg-black">
<div className={`grid min-h-0 flex-1 ${activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]'} bg-black`}>
<div
className={`relative min-h-0 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
onWheel={handleMappingWheel}
onPointerDown={handleMappingPointerDown}
onPointerMove={handleMappingPointerMove}
onPointerUp={stopMappingPointerDrag}
onPointerCancel={stopMappingPointerDrag}
className="flex min-h-0 flex-col"
>
{dicomPreview ? (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate3d(${mappingViewport.offsetX}px, ${mappingViewport.offsetY}px, 0) rotate(${rotation}deg) scale(${mappingViewport.scale})`,
transformOrigin: 'center center',
}}
>
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<div
className={`relative min-h-0 flex-1 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
onWheel={handleMappingWheel}
onPointerDown={handleMappingPointerDown}
onPointerMove={handleMappingPointerMove}
onPointerUp={stopMappingPointerDrag}
onPointerCancel={stopMappingPointerDrag}
>
{dicomPreview ? (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate3d(${mappingViewport.offsetX}px, ${mappingViewport.offsetY}px, 0) rotate(${rotation}deg) scale(${mappingViewport.scale})`,
transformOrigin: 'center center',
}}
>
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
{dicomStatus}
</div>
)}
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/70 px-3 py-2 text-right shadow-lg">
<p className="text-[9px] font-bold text-white/45">DICOM </p>
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
{safeSlice + 1} / {Math.max(totalSlices, 1)}
</p>
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
{dicomStatus}
</div>
)}
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/70 px-3 py-2 text-right shadow-lg">
<p className="text-[9px] font-bold text-white/45">DICOM </p>
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
{safeSlice + 1} / {Math.max(totalSlices, 1)}
</p>
</div>
{activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')}
</div>
<aside className="flex min-h-0 flex-col items-center border-l border-white/10 bg-[#0f172a] px-2 py-5">
<div className="relative min-h-[280px] w-8 flex-1">
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
<div className="relative min-h-[220px] w-8 flex-1">
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
<div
className="absolute bottom-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-cyan-400"
@@ -2122,6 +2158,7 @@ export function VoxelizationMappingView({
aria-label="项目库逆向分割映射视图切片导航"
/>
</div>
{activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
</aside>
</div>
</div>
@@ -3540,42 +3577,6 @@ export default function ReverseWorkspace({
</div>
<div className="min-h-0 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Layers size={18} className="text-cyan-500" />
</h3>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<div className="flex rounded-xl bg-slate-100 p-1">
{mappingDisplayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setMappingDisplayMode(mode.id)}
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
mappingDisplayMode === mode.id ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{mode.label}
</button>
))}
</div>
<button
onClick={() => setMappingRotation((value) => (value + 270) % 360)}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
title="左转 90°"
>
<RotateCcw size={14} />
</button>
<button
onClick={() => setMappingRotation((value) => (value + 90) % 360)}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
title="右转 90°"
>
<RotateCw size={14} />
</button>
</div>
</div>
<div className="min-h-0 flex-1">
<VoxelizationMappingView
project={project}
@@ -3587,6 +3588,39 @@ export default function ReverseWorkspace({
onSliceChange={setMappingSlice}
displayMode={mappingDisplayMode}
rotation={mappingRotation}
variant="library"
overlayPlacement="bottom"
toolbar={(
<>
<div className="flex rounded-xl bg-white/10 p-1">
{mappingDisplayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setMappingDisplayMode(mode.id)}
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
mappingDisplayMode === mode.id ? 'bg-white text-cyan-700 shadow-sm' : 'text-white/55 hover:text-white'
}`}
>
{mode.label}
</button>
))}
</div>
<button
onClick={() => setMappingRotation((value) => (value + 270) % 360)}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-black/60 text-white/65 hover:border-cyan-300/30 hover:text-cyan-100"
title="左转 90°"
>
<RotateCcw size={14} />
</button>
<button
onClick={() => setMappingRotation((value) => (value + 90) % 360)}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-black/60 text-white/65 hover:border-cyan-300/30 hover:text-cyan-100"
title="右转 90°"
>
<RotateCw size={14} />
</button>
</>
)}
/>
</div>
</div>

View File

@@ -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`

View File

@@ -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`:通过。

View File

@@ -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 主画布。

View File

@@ -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 继续放主画布下方。