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>