2026-05-20-21-25-19 项目库结果视图与加载缓存优化

This commit is contained in:
2026-05-20 21:47:18 +08:00
parent 6c9787803c
commit cc137437bc
7 changed files with 643 additions and 96 deletions

View File

@@ -26,6 +26,9 @@ import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectExportTa
import {
FusionThreeView,
VoxelizationMappingView,
getCachedDicomFusionVolume,
getCachedDicomPreview,
getCachedModelPreview,
dicomOpacityOptions as reverseDicomOpacityOptions,
displayOptions as reverseDisplayOptions,
} from './ReverseWorkspace';
@@ -433,11 +436,7 @@ function NativeStlViewer({
Promise.allSettled(
visibleFiles.map((fileName) =>
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=3500`)
.then((response) => {
if (!response.ok) throw new Error('模型预览数据加载失败');
return response.json() as Promise<ModelPreviewPayload>;
})
getCachedModelPreview(projectId, fileName, 3500)
.then((payload) => ({
payload,
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 },
@@ -490,13 +489,7 @@ function NativeStlViewer({
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
visibleFiles.forEach((fileName) => {
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
.then((response) => {
if (!response.ok) {
throw new Error('模型预览数据加载失败');
}
return response.json() as Promise<ModelPreviewPayload>;
})
getCachedModelPreview(projectId, fileName, detailLimit)
.then((payload) => {
if (disposed) return;
const geometry = new THREE.BufferGeometry();
@@ -697,11 +690,11 @@ export default function ProjectLibrary({
preloadedProjectIdsRef.current.add(project.id);
const maxSlice = Math.max((project.dicomCount || 1) - 1, 0);
if (project.dicomCount > 0) {
void api.getDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined);
void api.getDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined);
void getCachedDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined);
void getCachedDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined);
}
(project.stlFiles ?? []).slice(0, 3).forEach((fileName) => {
void fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=3500`).catch(() => undefined);
void getCachedModelPreview(project.id, fileName, 3500).catch(() => undefined);
});
};
@@ -860,7 +853,7 @@ export default function ProjectLibrary({
dicomRequestRef.current = requestId;
setDicomError('');
setIsSliceChanging(true);
api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
getCachedDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
.then((preview) => {
if (!cancelled && requestId === dicomRequestRef.current) {
setDicomPreview(preview);
@@ -891,7 +884,7 @@ export default function ProjectLibrary({
const start = Math.min(resultCutStart, resultCutEnd);
const end = Math.max(resultCutStart, resultCutEnd);
setResultFusionError('');
api.getDicomFusionVolume(selectedProject.id, start, end, 'soft')
getCachedDicomFusionVolume(selectedProject.id, start, end, 'soft')
.then((volume) => {
if (!cancelled) {
setResultFusionVolume(volume);
@@ -1566,8 +1559,8 @@ export default function ProjectLibrary({
)}
{viewMode === 'mask' && (
<div className="h-full grid grid-cols-1 gap-6 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_340px]">
<div className="min-h-[560px]">
<div className="grid h-full min-h-0 grid-cols-1 items-stretch gap-6 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_340px]">
<div className="flex min-h-[620px] min-w-0 flex-col">
{latestSegmentationResult ? (
<FusionThreeView
project={selectedProject}
@@ -1581,9 +1574,10 @@ export default function ProjectLibrary({
cutEnabled={latestSegmentationResult.cutEnabled ?? false}
cutStart={resultCutStart}
cutEnd={resultCutEnd}
viewPreset="libraryResult"
/>
) : (
<div className="flex h-full min-h-[560px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
<div className="flex h-full min-h-[620px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
</div>
)}
@@ -1594,42 +1588,7 @@ export default function ProjectLibrary({
)}
</div>
<div className="min-h-[560px]">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 font-bold text-slate-700">
<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">
{displayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setResultDisplayMode(mode.id)}
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
resultDisplayMode === mode.id ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{mode.label}
</button>
))}
</div>
<button
onClick={() => setResultRotation((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={() => setResultRotation((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="flex min-h-[620px] min-w-0 flex-col">
{latestSegmentationResult ? (
<VoxelizationMappingView
project={selectedProject}
@@ -1641,9 +1600,41 @@ export default function ProjectLibrary({
onSliceChange={setResultPreviewSlice}
displayMode={resultDisplayMode}
rotation={resultRotation}
variant="library"
toolbar={(
<>
<div className="flex rounded-xl bg-white/10 p-1">
{displayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setResultDisplayMode(mode.id)}
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
resultDisplayMode === mode.id ? 'bg-white text-cyan-700 shadow-sm' : 'text-white/55 hover:text-white'
}`}
>
{mode.label}
</button>
))}
</div>
<button
onClick={() => setResultRotation((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={() => setResultRotation((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 className="flex h-full min-h-[520px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
<div className="flex h-full min-h-[620px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
</div>
)}

View File

@@ -19,7 +19,7 @@ import * as THREE from 'three';
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api';
interface ModelPreviewPayload {
export interface ModelPreviewPayload {
fileName: string;
triangleCount: number;
sampledTriangles: number;
@@ -46,6 +46,15 @@ interface AxisVector2D {
type AxisProjection = Record<AxisKey, AxisVector2D>;
type WorkspaceLeaveGuard = () => Promise<boolean>;
interface WorkspaceLoadState {
ready: boolean;
phase: string;
loaded: number;
total: number;
startedAt: number;
error: string;
}
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
@@ -108,6 +117,66 @@ const defaultAxisProjection: AxisProjection = {
y: { dx: -10, dy: 10, opacity: 0.82 },
z: { dx: 0, dy: -axisInsetLength, opacity: 0.95 },
};
const dicomPreviewCache = new Map<string, Promise<DicomPreview>>();
const dicomFusionVolumeCache = new Map<string, Promise<DicomFusionVolume>>();
const modelPreviewCache = new Map<string, Promise<ModelPreviewPayload>>();
function rememberRequest<T>(cache: Map<string, Promise<T>>, key: string, loader: () => Promise<T>) {
const cached = cache.get(key);
if (cached) {
return cached;
}
const request = loader().catch((error) => {
cache.delete(key);
throw error;
});
cache.set(key, request);
return request;
}
export function getCachedDicomPreview(
projectId: string,
slice: number,
plane: DicomPreview['plane'] = 'axial',
mode: DicomPreview['mode'] = 'default',
) {
return rememberRequest(
dicomPreviewCache,
`${projectId}:${plane}:${mode}:${slice}`,
() => api.getDicomPreview(projectId, slice, plane, mode),
);
}
export function getCachedDicomFusionVolume(
projectId: string,
start: number,
end: number,
mode: DicomPreview['mode'] = 'soft',
) {
const safeStart = Math.min(start, end);
const safeEnd = Math.max(start, end);
return rememberRequest(
dicomFusionVolumeCache,
`${projectId}:${mode}:${safeStart}:${safeEnd}`,
() => api.getDicomFusionVolume(projectId, safeStart, safeEnd, mode),
);
}
export function getCachedModelPreview(projectId: string, fileName: string, limit: number) {
const safeLimit = Math.max(1, Math.round(limit));
return rememberRequest(
modelPreviewCache,
`${projectId}:${fileName}:${safeLimit}`,
() => fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${safeLimit}`)
.then((response) => {
if (!response.ok) {
throw new Error('模型预览数据加载失败');
}
return response.json() as Promise<ModelPreviewPayload>;
}),
);
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
@@ -437,6 +506,7 @@ export function FusionThreeView({
cutEnabled,
cutStart,
cutEnd,
viewPreset = 'workspace',
}: {
project: Project;
volume: DicomFusionVolume | null;
@@ -449,6 +519,7 @@ export function FusionThreeView({
cutEnabled: boolean;
cutStart: number;
cutEnd: number;
viewPreset?: 'workspace' | 'libraryResult';
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const modelPoseRef = useRef(modelPose);
@@ -576,11 +647,7 @@ export function FusionThreeView({
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
Promise.allSettled(stlFiles.map((fileName, index) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
.then((response) => {
if (!response.ok) throw new Error('模型预览加载失败');
return response.json() as Promise<ModelPreviewPayload>;
})
getCachedModelPreview(project.id, fileName, detailLimit)
.then((payload) => {
if (disposed) return;
const style = moduleStyles[fileName] ?? {
@@ -653,7 +720,14 @@ export function FusionThreeView({
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
});
const defaultRootPose = {
const defaultRootPose = viewPreset === 'libraryResult' ? {
rotateX: THREE.MathUtils.degToRad(70),
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0.02,
scale: 0.94,
} : {
rotateX: THREE.MathUtils.degToRad(58),
rotateY: 0,
rotateZ: THREE.MathUtils.degToRad(-18),
@@ -804,6 +878,7 @@ export function FusionThreeView({
cutEnabled,
cutStart,
cutEnd,
viewPreset,
]);
return (
@@ -938,11 +1013,7 @@ function CutSectionPreview({
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
Promise.allSettled(stlFiles.map((fileName, index) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
.then((response) => {
if (!response.ok) throw new Error('模型切面加载失败');
return response.json() as Promise<ModelPreviewPayload>;
})
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000))
.then((payload) => {
if (disposed) return;
const geometry = new THREE.BufferGeometry();
@@ -1794,6 +1865,8 @@ export function VoxelizationMappingView({
onSliceChange,
displayMode,
rotation,
variant = 'workspace',
toolbar,
}: {
project: Project | null;
moduleStyles: Record<string, ModuleStyle>;
@@ -1804,6 +1877,8 @@ export function VoxelizationMappingView({
onSliceChange: (slice: number) => void;
displayMode: MappingDisplayMode;
rotation: number;
variant?: 'workspace' | 'library';
toolbar?: React.ReactNode;
}) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -1825,6 +1900,7 @@ export function VoxelizationMappingView({
const safeSlice = clamp(slice, 0, maxSlice);
const stlFiles = project?.stlFiles ?? [];
const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length;
const isLibraryVariant = variant === 'library';
useEffect(() => {
if (!project?.dicomCount) {
@@ -1835,7 +1911,7 @@ export function VoxelizationMappingView({
let disposed = false;
setDicomStatus('正在载入 DICOM Base Layer...');
api.getDicomPreview(project.id, safeSlice, 'axial', displayMode)
getCachedDicomPreview(project.id, safeSlice, 'axial', displayMode)
.then((preview) => {
if (disposed) return;
setDicomPreview(preview);
@@ -1862,13 +1938,7 @@ export function VoxelizationMappingView({
let disposed = false;
setOverlayStatus('正在载入 STL 构件层级...');
Promise.allSettled(stlFiles.map((fileName) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
.then((response) => {
if (!response.ok) {
throw new Error('STL 构件预览加载失败');
}
return response.json() as Promise<ModelPreviewPayload>;
})
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000))
.then((payload) => ({ fileName, payload }))
))).then((results) => {
if (disposed) return;
@@ -1982,6 +2052,82 @@ export function VoxelizationMappingView({
}
};
if (isLibraryVariant) {
return (
<div className="relative flex h-full min-h-[560px] flex-col overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-[#030712] px-4 py-3">
<div className="min-w-0 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[11px] font-bold text-white/70">
</div>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{toolbar}
<button
onClick={resetMappingViewport}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-xl border border-white/10 bg-black/60 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
title="重置逆向分割映射视图位置"
>
<RefreshCcw size={13} />
</button>
</div>
</div>
<div className="grid min-h-0 flex-1 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}
>
{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>
<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">
<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"
style={{ height: `${slicePercent}%` }}
/>
<input
type="range"
min="0"
max={maxSlice}
value={safeSlice}
onChange={(event) => onSliceChange(Number(event.target.value))}
className="mapping-slice-dark-vertical-input"
aria-label="项目库逆向分割映射视图切片导航"
/>
</div>
</aside>
</div>
</div>
);
}
return (
<div className="relative flex h-full min-h-[520px] flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-sm">
<div className="flex items-center justify-between gap-3 border-b border-slate-100 bg-white px-4 py-3">
@@ -2150,8 +2296,16 @@ export default function ReverseWorkspace({
const [saveStatus, setSaveStatus] = useState('');
const [exporting, setExporting] = useState(false);
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({
ready: false,
phase: '正在读取项目配置...',
loaded: 0,
total: 1,
startedAt: Date.now(),
error: '',
});
const workspaceLoadProjectRef = useRef('');
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
const saveToastTimerRef = useRef<number | null>(null);
@@ -2319,23 +2473,14 @@ export default function ReverseWorkspace({
});
};
const getFusionCacheKey = (projectIdValue: string, start: number, end: number, mode = 'soft') => `${projectIdValue}:${mode}:${start}:${end}`;
const loadFusionVolume = async (start: number, end: number, useCache = true) => {
const loadFusionVolume = async (start: number, end: number) => {
if (!project?.dicomCount) return null;
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
const safeA = clamp(start, 0, maxSliceValue);
const safeB = clamp(end, 0, maxSliceValue);
const safeStart = Math.min(safeA, safeB);
const rangeEnd = Math.max(safeA, safeB);
const cacheKey = getFusionCacheKey(project.id, safeStart, rangeEnd);
const cached = fusionVolumeCacheRef.current.get(cacheKey);
if (useCache && cached) {
setFusionVolume(cached);
return cached;
}
const volumePayload = await api.getDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft');
fusionVolumeCacheRef.current.set(cacheKey, volumePayload);
const volumePayload = await getCachedDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft');
return volumePayload;
};
@@ -2352,13 +2497,7 @@ export default function ReverseWorkspace({
const modelBox = new THREE.Box3();
const results = await Promise.allSettled(visibleFiles.map((fileName) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=1000`)
.then((response) => {
if (!response.ok) {
throw new Error('模型边界载入失败');
}
return response.json() as Promise<ModelPreviewPayload>;
})
getCachedModelPreview(project.id, fileName, 1000)
)));
results.forEach((result) => {
if (result.status !== 'fulfilled' || !result.value.bounds) {
@@ -2422,6 +2561,15 @@ export default function ReverseWorkspace({
};
useEffect(() => {
workspaceLoadProjectRef.current = '';
setWorkspaceLoadState({
ready: false,
phase: '正在读取项目配置...',
loaded: 0,
total: 1,
startedAt: Date.now(),
error: '',
});
api.getProject(projectId).then((item) => {
setProject(item);
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
@@ -2466,6 +2614,14 @@ export default function ReverseWorkspace({
}).catch(() => {
setProject(null);
setFusionVolume(null);
setWorkspaceLoadState({
ready: false,
phase: '项目配置读取失败',
loaded: 0,
total: 1,
startedAt: Date.now(),
error: '项目配置读取失败',
});
savedWorkspaceSnapshotRef.current = '';
});
}, [projectId]);
@@ -2722,6 +2878,161 @@ export default function ReverseWorkspace({
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
const stretchEnabled = Boolean(project && fusionVolume && isOrthogonalModelPose(modelPose));
const workspaceProgress = workspaceLoadState.total > 0
? Math.round((workspaceLoadState.loaded / workspaceLoadState.total) * 100)
: 0;
const workspaceElapsedSeconds = Math.max((Date.now() - workspaceLoadState.startedAt) / 1000, 0.1);
const workspaceLoadSpeed = workspaceLoadState.loaded / workspaceElapsedSeconds;
useEffect(() => {
if (!project?.dicomCount) {
return undefined;
}
if (workspaceLoadProjectRef.current === project.id) {
return undefined;
}
let cancelled = false;
const stlFilesForLoad = project.stlFiles ?? [];
const fusionStart = Math.min(displayStart, displayEnd);
const fusionEnd = Math.max(displayStart, displayEnd);
const previewLimit = selectedDisplay.limit;
const mappingPreviewLimit = Math.max(previewLimit, 200000);
const total = 2 + stlFilesForLoad.length * 2;
const startedAt = Date.now();
let loaded = 0;
const updateLoadState = (phase: string, error = '') => {
if (cancelled) {
return;
}
setWorkspaceLoadState({
ready: false,
phase,
loaded,
total,
startedAt,
error,
});
};
const markLoaded = (phase: string) => {
loaded += 1;
updateLoadState(phase);
};
updateLoadState('正在载入 DICOM 三维体与 STL 构件预览...');
const tasks: Array<Promise<unknown>> = [
getCachedDicomFusionVolume(project.id, fusionStart, fusionEnd, 'soft')
.then((volume) => {
if (!cancelled) {
setFusionVolume(volume);
}
markLoaded('DICOM 三维融合体已载入');
}),
getCachedDicomPreview(project.id, safeMappingSlice, 'axial', mappingDisplayMode)
.then(() => markLoaded('DICOM 切片预览已载入')),
...stlFilesForLoad.map((fileName) => (
getCachedModelPreview(project.id, fileName, previewLimit)
.then(() => markLoaded(`三维模型预览已缓存:${fileName.replace(/\.stl$/i, '')}`))
)),
...stlFilesForLoad.map((fileName) => (
getCachedModelPreview(project.id, fileName, mappingPreviewLimit)
.then(() => markLoaded(`二维映射网格已缓存:${fileName.replace(/\.stl$/i, '')}`))
)),
];
Promise.allSettled(tasks).then((results) => {
if (cancelled) {
return;
}
const fusionFailed = results[0]?.status === 'rejected';
if (fusionFailed) {
setFusionVolume(null);
setWorkspaceLoadState({
ready: false,
phase: 'DICOM 三维融合体载入失败',
loaded,
total,
startedAt,
error: 'DICOM 三维融合体载入失败,请检查数据或刷新页面重试。',
});
return;
}
workspaceLoadProjectRef.current = project.id;
setWorkspaceLoadState({
ready: true,
phase: '逆向工作区已就绪',
loaded: total,
total,
startedAt,
error: '',
});
});
return () => {
cancelled = true;
};
}, [
project?.id,
project?.dicomCount,
project?.stlFiles?.join('|'),
displayStart,
displayEnd,
safeMappingSlice,
selectedDisplay.limit,
mappingDisplayMode,
]);
if (!workspaceLoadState.ready) {
return (
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
<div className="w-full max-w-3xl rounded-3xl border border-slate-100 bg-white p-8 shadow-sm">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.24em] text-blue-500">Reverse Workspace</p>
<h2 className="mt-2 text-2xl font-black text-slate-900"></h2>
<p className="mt-2 text-sm font-semibold text-slate-500">
DICOM STL
</p>
</div>
<span className="rounded-2xl bg-slate-950 px-4 py-2 font-mono text-sm font-bold text-cyan-100">
{Math.max(0, Math.min(100, workspaceProgress))}%
</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${Math.max(4, Math.min(100, workspaceProgress))}%` }}
/>
</div>
<div className="mt-5 grid gap-3 text-xs font-bold text-slate-500 sm:grid-cols-3">
<div className="rounded-2xl bg-slate-50 px-4 py-3">
<span className="block text-[10px] uppercase tracking-widest text-slate-400"></span>
<span className="mt-1 block truncate text-slate-700">{workspaceLoadState.phase}</span>
</div>
<div className="rounded-2xl bg-slate-50 px-4 py-3">
<span className="block text-[10px] uppercase tracking-widest text-slate-400"></span>
<span className="mt-1 block font-mono text-slate-700">
{workspaceLoadState.loaded} / {workspaceLoadState.total}
</span>
</div>
<div className="rounded-2xl bg-slate-50 px-4 py-3">
<span className="block text-[10px] uppercase tracking-widest text-slate-400"></span>
<span className="mt-1 block font-mono text-slate-700">
{workspaceLoadSpeed.toFixed(1)} /
</span>
</div>
</div>
{workspaceLoadState.error && (
<div className="mt-5 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700">
{workspaceLoadState.error}
</div>
)}
</div>
</div>
);
}
return (
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">

View File

@@ -174,3 +174,61 @@
.mapping-slice-vertical-input:active::-moz-range-thumb {
cursor: grabbing;
}
.mapping-slice-dark-vertical-input {
appearance: none;
-webkit-appearance: none;
background: transparent;
height: 100%;
inset: 0;
position: absolute;
width: 100%;
direction: rtl;
writing-mode: vertical-rl;
}
.mapping-slice-dark-vertical-input:focus {
outline: none;
}
.mapping-slice-dark-vertical-input::-webkit-slider-runnable-track {
background: transparent;
border: 0;
width: 6px;
}
.mapping-slice-dark-vertical-input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 20px;
width: 20px;
}
.mapping-slice-dark-vertical-input::-moz-range-track {
background: transparent;
border: 0;
width: 6px;
}
.mapping-slice-dark-vertical-input::-moz-range-thumb {
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 14px;
width: 14px;
}
.mapping-slice-dark-vertical-input:active::-webkit-slider-thumb {
cursor: grabbing;
}
.mapping-slice-dark-vertical-input:active::-moz-range-thumb {
cursor: grabbing;
}

View File

@@ -0,0 +1,58 @@
# 实现方案-2026-05-20-21-25-19
## 实现方案文档路径
`工程分析/实现方案-2026-05-20-21-25-19.md`
## 修改目标
1. 优化项目库“逆向分割结果”中的三维融合视图默认/复位视角,使复位后呈现用户示意的正向融合观察效果。
2.`VoxelizationMappingView` 增加项目库紧凑展示模式去掉无意义标签把窗宽按钮、复位按钮、视图标题、DICOM 切片位置和竖向导航重新布局。
3. 调整项目库结果区左右面板高度与底部对齐。
4. 为逆向工作区增加加载中页面,展示加载进度、加载速度和当前阶段,数据未就绪时不显示完整工作区。
5. 增加 DICOM 融合体与 STL 预览数据的内存缓存,减少跨模块返回后的重复加载。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/index.css`
- `工程分析/经验记录.md`
## 技术路线
-`ReverseWorkspace.tsx` 中增加模块级缓存 Map用于缓存 DICOM fusion volume、STL preview、映射视图预览数据。
-`FusionThreeView` 增加 `variant` 或复位视角参数,项目库复用同一组件但使用较适合结果复核的默认视角。
-`VoxelizationMappingView` 增加 `variant="workspace" | "library"`,项目库模式采用黑色画布、精简标题和窄导航栏;工作区保持完整操作信息。
- 用加载指标组合逆向工作区所需数据状态,未完成时显示独立加载卡片。
- 调整项目库逆向结果布局为等高网格,并保证左右图容器底部一致。
## 执行步骤
1. 阅读现有 `ProjectLibrary.tsx``ReverseWorkspace.tsx` 的视图复用和数据加载逻辑。
2. 写入本次需求、实现、测试方案文档。
3. 修改融合视图与映射视图组件参数,补齐紧凑模式和复位视角。
4. 增加数据缓存与加载页,避免跨页面返回时重复拉取大数据。
5. 调整项目库结果区布局和控件位置。
6. 执行类型检查、构建、服务重启与健康检查。
7. 追加经验记录并提交推送到 Gitea。
## 兼容性与回滚方案
- 新增 UI 变体保持默认值为工作区模式,避免影响已有调用。
- 缓存仅保存在浏览器内存中,刷新页面会自然清空;若出现旧数据问题,可删除缓存分支回退为原请求逻辑。
- 如果紧凑模式影响项目库结果展示,可只回退 `variant="library"` 调用,不影响逆向工作区主流程。
## 预计文件变更
- 修改 `ReverseWorkspace.tsx`:组件参数、缓存、加载页、视图布局。
- 修改 `ProjectLibrary.tsx`:调用参数、布局对齐、控件位置。
- 视需要修改 `index.css`:竖向导航样式微调。
- 新增本次三份工程分析文档,并追加经验记录。
## 提交与部署策略
- 只暂存本次修改的源码和本次工程分析文档。
- commit message 使用 `2026-05-20-21-25-19 项目库结果视图与加载缓存优化`
- 推送到 `http://192.168.31.5:5002/admin/REVOXELSEG_DICOM.git``main` 分支。
- 使用 `tmux` 会话 `revoxelseg-dicom` 重新部署并验证。

View File

@@ -0,0 +1,61 @@
# 测试方案-2026-05-20-21-25-19
## 测试方案文档路径
`工程分析/测试方案-2026-05-20-21-25-19.md`
## 静态检查
-`WebSite/` 执行 `npm run lint`,确认 TypeScript 类型检查通过。
## 构建检查
-`WebSite/` 执行 `npm run build`,确认 Vite 生产构建通过。
## 关键业务场景验证
- 项目库进入“逆向分割结果”,确认左右两张图底部对齐。
- 点击项目库三维融合视角“位置重置”,确认出现“三维融合视角已复位”类反馈,视角呈现正向融合观察效果。
- 项目库逆向分割映射视图不再显示“逆向分割映射视图”标题行,不再显示 `Base DICOM``Overlay Label Map` 标签。
- 项目库映射视图同一行显示窗宽按钮和位置重置按钮。
- 项目库映射视图黑色画布左上显示“逆向分割映射视图”,右下显示 DICOM 切片位置,右侧只保留竖向进度条。
- 逆向工作区首次进入时,在数据加载完成前显示加载进度、速度和阶段;完成后进入完整工作区。
- 从逆向工作区切到其他模块再返回,确认已加载数据复用缓存,减少重复加载等待。
## 医学影像数据相关边界验证
- DICOM 切片范围为 300 层时,右侧竖向进度条顶/底/当前值显示正确。
- 切片切换后DICOM 底图和 Overlay Label Map 仍同步更新。
- 位置重置只改变浏览视口,不改变模型位姿与导出参数。
## 部署验证
- 使用 `tmux` 会话 `revoxelseg-dicom` 启动:
- `cd WebSite`
- `npm run serve -- --host 0.0.0.0 --port 4000`
- 验证:
- `http://127.0.0.1:4000/api/health`
- `http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- 检查 `git status --short`,确认只暂存本次相关文件。
- commit message 包含 `2026-05-20-21-25-19` 与简要描述。
- 推送到 Gitea 后确认远端更新成功。
## 风险与回归关注点
- 项目库紧凑变体不能影响逆向工作区完整编辑态。
- 缓存不能导致项目切换后显示上一个项目的 DICOM/STL 数据。
- 加载页不能在部分非关键数据失败时永久阻塞用户进入工作区。
## 执行结果
- `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`
- DICOM/项目数据接口抽查:`/api/projects``/api/projects/head-ct-demo/dicom-fusion-volume``/api/projects/head-ct-demo/dicom-preview` 均有有效响应。
- 页面级截图:使用系统 Chrome 生成 `/tmp/revoxelseg-home.png`,页面可渲染;因当前应用启动时会自动退出登录,未使用无交互截图验证登录后的三维画布。
- 备注:尝试临时安装 `playwright-core` 做交互截图验证,但安装过程无输出且超时,已终止,未产生仓库文件变更。

View File

@@ -1333,3 +1333,21 @@ C. 解决问题方案
D. 后续如何避免问题
新增影像查看交互时必须先判断它属于“浏览视口”还是“结果位姿”。浏览视口状态默认不进入保存和导出;只有会改变模型与 DICOM 空间关系的参数才进入 `modelPose`、保存快照和导出数据。
## 2026-05-20-21-25-19 结果复核视图与加载缓存要拆分展示形态
A. 具体问题
用户要求项目库“逆向分割结果”复用真实三维融合和二维映射效果,但项目库是复核场景,不需要工作区里的完整编辑控件;同时逆向工作区和项目库在跨模块返回时重复载入 DICOM/STL 预览,等待感明显。
B. 产生问题原因
此前 `FusionThreeView``VoxelizationMappingView` 虽然已复用到项目库,但组件只有一种工作区展示形态,项目库只能带着 Base/Overlay 标签、统计面板和较宽导航一起展示。DICOM 预览、DICOM fusion volume 和 STL preview 的缓存也在组件或页面局部,页面卸载后缓存随之丢失。
C. 解决问题方案
将只读预览数据缓存提升为模块级缓存函数,项目库、逆向工作区和 STL 查看器统一使用 `getCachedDicomPreview``getCachedDicomFusionVolume``getCachedModelPreview``FusionThreeView` 增加项目库复核视角预设,复位后呈现更正向的三维融合观察角度;`VoxelizationMappingView` 增加 `library` 紧凑变体把窗宽、旋转和位置重置放在同一黑色工具行画布内只保留视图名、DICOM 切片位置和窄竖向进度条。逆向工作区首次进入前增加加载页,显示进度、阶段和速度。
D. 后续如何避免问题
同一个医学视图组件被复用到“编辑工作区”和“项目库复核”时,应优先通过 `variant` 或视角预设区分展示密度,而不是复制第二套近似实现。大体积医学预览数据应使用按项目、切片、窗宽、文件和采样精度组成的缓存 key避免跨项目串数据加载页只阻塞首次必要数据不把普通浏览视口变化误判为需要重新进入全屏加载。

View File

@@ -0,0 +1,50 @@
# 需求分析-2026-05-20-21-25-19
## 开始时间
2026-05-20-21-25-19
## 原始需求摘要
用户要求继续优化项目库与逆向工作区的逆向分割结果查看体验:项目库的三维融合视角复位后应呈现指定观察角度;项目库右侧逆向分割映射视图需更紧凑,删除无意义标题行与 Base/Overlay 标签,将窗宽按钮和位置重置整合在同一行,黑色影像区左上显示视图名、右下显示 DICOM 切片位置,右侧仅保留一个进度条;项目库逆向分割结果左右图底部对齐;逆向工作区加载未完成前不显示半成品工作区,而显示进度、速度等加载反馈;从逆向工作区或项目库切换到其他模块再返回时,应减轻重复加载。
## 业务目标
- 让项目库中的逆向分割结果更接近保存结果复核视图,减少控件占用面积。
- 让项目库左右两张结果图在视觉底线和布局高度上对齐,便于横向比较。
- 避免用户在数据未加载完时看到半成品工作区,降低误解。
- 对大体积 DICOM/STL 预览数据做页面级缓存,减少跨模块返回时的重复请求和等待。
## 输入与输出
- 输入项目库最新逆向分割结果、DICOM 融合体数据、STL 预览数据、构件样式、切片位置和窗宽模式。
- 输出:项目库紧凑版逆向分割结果视图、逆向工作区加载页、复用缓存后的三维融合视图和二维映射视图。
## 影响范围
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/index.css`
- `工程分析/经验记录.md`
- 本次需求/实现/测试方案文档
## 关键约束
- 医学影像主画布不能被无意义标签遮挡。
- 位置重置应只重置浏览视角,不修改模型位姿或导出结果。
- 缓存只能缓存只读预览数据,不应缓存用户保存状态导致脏状态误判。
- 项目库和逆向工作区复用同一真实视图组件时,必须允许项目库使用更紧凑的 UI 变体。
- 提交时不能混入当前工作树已有的历史删除和软著资料。
## 风险点
- Three.js 组件缓存与 React 生命周期若处理不当,切换页面后可能出现旧场景未更新或重复加载。
- 右侧竖向进度条如果在紧凑布局中宽度过大,会继续挤压 DICOM 画布。
- 加载页若依赖过严,可能在部分 STL 失败时一直不进入工作区。
- 项目库复用工作区组件时,需要避免把工作区专用操作暴露到项目库。
## 默认假设
- “减轻重复加载”优先通过浏览器内存级缓存完成,暂不引入 IndexedDB 或后端缓存。
- 加载完成标准以 DICOM 融合体加载完成、STL 预览基础数据可用为主;个别 STL 预览失败时显示错误但不永久阻塞页面。
- 项目库逆向分割映射视图可以使用紧凑变体,逆向工作区保留更完整的编辑态信息。