2026-05-20-22-07-46 导出命名与映射视图摘要优化
This commit is contained in:
@@ -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 : '导出包生成失败' });
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
57
工程分析/实现方案-2026-05-20-22-07-46.md
Normal file
57
工程分析/实现方案-2026-05-20-22-07-46.md
Normal 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`。
|
||||
56
工程分析/测试方案-2026-05-20-22-07-46.md
Normal file
56
工程分析/测试方案-2026-05-20-22-07-46.md
Normal 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`:通过。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.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 主画布。
|
||||
|
||||
50
工程分析/需求分析-2026-05-20-22-07-46.md
Normal file
50
工程分析/需求分析-2026-05-20-22-07-46.md
Normal 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 继续放主画布下方。
|
||||
Reference in New Issue
Block a user