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>
|
||||
|
||||
Reference in New Issue
Block a user