2026-05-20-21-25-19 项目库结果视图与加载缓存优化
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
58
工程分析/实现方案-2026-05-20-21-25-19.md
Normal file
58
工程分析/实现方案-2026-05-20-21-25-19.md
Normal 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` 重新部署并验证。
|
||||
61
工程分析/测试方案-2026-05-20-21-25-19.md
Normal file
61
工程分析/测试方案-2026-05-20-21-25-19.md
Normal 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` 做交互截图验证,但安装过程无输出且超时,已终止,未产生仓库文件变更。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -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,避免跨项目串数据;加载页只阻塞首次必要数据,不把普通浏览视口变化误判为需要重新进入全屏加载。
|
||||
|
||||
50
工程分析/需求分析-2026-05-20-21-25-19.md
Normal file
50
工程分析/需求分析-2026-05-20-21-25-19.md
Normal 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 预览失败时显示错误但不永久阻塞页面。
|
||||
- 项目库逆向分割映射视图可以使用紧凑变体,逆向工作区保留更完整的编辑态信息。
|
||||
Reference in New Issue
Block a user