2026-05-24-17-29-17 增加构件加载进度并修正滚轮事件
This commit is contained in:
@@ -140,3 +140,8 @@ cd WebSite
|
|||||||
npm run build
|
npm run build
|
||||||
npm run serve -- --host 0.0.0.0 --port 4000
|
npm run serve -- --host 0.0.0.0 --port 4000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 六、2026-05-24 交互修正
|
||||||
|
|
||||||
|
- 逆向工作区切换构件层级时,可见 STL 构件会显示加载进度,避免高精度预览加载期间误判为构件不显示。
|
||||||
|
- DICOM 与逆向分割映射画布的滚轮缩放使用非被动 wheel 监听,修正浏览器控制台 `Unable to preventDefault inside passive event listener invocation` 警告。
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview; rotation: number; resetSignal: number }) {
|
function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview; rotation: number; resetSignal: number }) {
|
||||||
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
@@ -383,7 +384,13 @@ function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview
|
|||||||
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
}, [resetSignal]);
|
}, [resetSignal]);
|
||||||
|
|
||||||
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
useEffect(() => {
|
||||||
|
const element = viewportRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||||
setViewport((current) => ({
|
setViewport((current) => ({
|
||||||
@@ -391,6 +398,13 @@ function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview
|
|||||||
scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)),
|
scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
element.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('wheel', handleWheel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
return;
|
return;
|
||||||
@@ -431,8 +445,8 @@ function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative flex h-full w-full items-center justify-center overflow-hidden rounded-xl bg-black ${isPanning ? 'cursor-grabbing' : 'cursor-grab'}`}
|
ref={viewportRef}
|
||||||
onWheel={handleWheel}
|
className={`relative flex h-full w-full touch-none items-center justify-center overflow-hidden rounded-xl bg-black ${isPanning ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={stopPointerDrag}
|
onPointerUp={stopPointerDrag}
|
||||||
@@ -1961,15 +1975,13 @@ export default function ProjectLibrary({
|
|||||||
{dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
|
{dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onMouseDown={() => startSliceStep(1)}
|
onPointerDown={() => {
|
||||||
onMouseUp={stopSliceStep}
|
|
||||||
onMouseLeave={stopSliceStep}
|
|
||||||
onTouchStart={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
startSliceStep(1);
|
startSliceStep(1);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={stopSliceStep}
|
onPointerUp={stopSliceStep}
|
||||||
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
|
onPointerLeave={stopSliceStep}
|
||||||
|
onPointerCancel={stopSliceStep}
|
||||||
|
className="mb-3 flex h-8 w-8 touch-none items-center justify-center rounded-full border border-slate-100 bg-white text-slate-500 shadow-sm hover:border-blue-100 hover:text-blue-600"
|
||||||
title="长按向上移动切片"
|
title="长按向上移动切片"
|
||||||
>
|
>
|
||||||
<ChevronUp size={16} />
|
<ChevronUp size={16} />
|
||||||
@@ -1987,15 +1999,13 @@ export default function ProjectLibrary({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onMouseDown={() => startSliceStep(-1)}
|
onPointerDown={() => {
|
||||||
onMouseUp={stopSliceStep}
|
|
||||||
onMouseLeave={stopSliceStep}
|
|
||||||
onTouchStart={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
startSliceStep(-1);
|
startSliceStep(-1);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={stopSliceStep}
|
onPointerUp={stopSliceStep}
|
||||||
className="mt-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
|
onPointerLeave={stopSliceStep}
|
||||||
|
onPointerCancel={stopSliceStep}
|
||||||
|
className="mt-3 flex h-8 w-8 touch-none items-center justify-center rounded-full border border-slate-100 bg-white text-slate-500 shadow-sm hover:border-blue-100 hover:text-blue-600"
|
||||||
title="长按向下移动切片"
|
title="长按向下移动切片"
|
||||||
>
|
>
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} />
|
||||||
|
|||||||
@@ -2154,11 +2154,13 @@ export function VoxelizationMappingView({
|
|||||||
}) {
|
}) {
|
||||||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const mappingViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||||
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
|
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
|
||||||
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
|
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
|
||||||
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
|
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
|
||||||
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||||||
|
const [overlayLoadState, setOverlayLoadState] = useState({ loading: false, loaded: 0, total: 0, phase: '' });
|
||||||
const [mappingViewport, setMappingViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
const [mappingViewport, setMappingViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
const mappingPanRef = useRef({
|
const mappingPanRef = useRef({
|
||||||
active: false,
|
active: false,
|
||||||
@@ -2210,15 +2212,38 @@ export function VoxelizationMappingView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project || !visibleStlFiles.length) {
|
if (!project || !visibleStlFiles.length) {
|
||||||
setModelPreviews({});
|
setModelPreviews({});
|
||||||
|
setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||||||
|
setOverlayLoadState({ loading: false, loaded: 0, total: 0, phase: '' });
|
||||||
setOverlayStatus(stlFiles.length ? '当前没有可见 STL 构件' : '当前项目没有 STL 构件');
|
setOverlayStatus(stlFiles.length ? '当前没有可见 STL 构件' : '当前项目没有 STL 构件');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let loaded = 0;
|
||||||
|
const total = visibleStlFiles.length;
|
||||||
|
const previewLimit = Math.max(detailLimit, 500000);
|
||||||
|
const updateLoadProgress = (phase: string) => {
|
||||||
|
if (!disposed) {
|
||||||
|
setOverlayLoadState({ loading: true, loaded, total, phase });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setModelPreviews({});
|
||||||
|
setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||||||
|
setOverlayLoadState({ loading: true, loaded: 0, total, phase: '正在载入可见构件' });
|
||||||
setOverlayStatus('正在载入可见 STL 构件层级...');
|
setOverlayStatus('正在载入可见 STL 构件层级...');
|
||||||
Promise.allSettled(visibleStlFiles.map((fileName) => (
|
Promise.allSettled(visibleStlFiles.map((fileName) => (
|
||||||
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 500000))
|
getCachedModelPreview(project.id, fileName, previewLimit)
|
||||||
.then((payload) => ({ fileName, payload }))
|
.then((payload) => {
|
||||||
|
loaded += 1;
|
||||||
|
updateLoadProgress(fileName.replace(/\.stl$/i, ''));
|
||||||
|
return { fileName, payload };
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
loaded += 1;
|
||||||
|
updateLoadProgress(`${fileName.replace(/\.stl$/i, '')} 载入失败`);
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
))).then((results) => {
|
))).then((results) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const nextPreviews: Record<string, ModelPreviewPayload> = {};
|
const nextPreviews: Record<string, ModelPreviewPayload> = {};
|
||||||
@@ -2228,6 +2253,7 @@ export function VoxelizationMappingView({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setModelPreviews(nextPreviews);
|
setModelPreviews(nextPreviews);
|
||||||
|
setOverlayLoadState({ loading: false, loaded: total, total, phase: '' });
|
||||||
setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
|
setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2283,6 +2309,27 @@ export function VoxelizationMappingView({
|
|||||||
totalSlices,
|
totalSlices,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = mappingViewportRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
setMappingViewport((current) => ({
|
||||||
|
...current,
|
||||||
|
scale: clamp(current.scale * scaleFactor, 0.45, 6),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('wheel', handleWheel);
|
||||||
|
};
|
||||||
|
}, [isLibraryVariant]);
|
||||||
|
|
||||||
const stepSlice = (delta: number) => {
|
const stepSlice = (delta: number) => {
|
||||||
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
||||||
};
|
};
|
||||||
@@ -2291,14 +2338,6 @@ export function VoxelizationMappingView({
|
|||||||
const resetMappingViewport = () => {
|
const resetMappingViewport = () => {
|
||||||
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
};
|
};
|
||||||
const handleMappingWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
|
||||||
setMappingViewport((current) => ({
|
|
||||||
...current,
|
|
||||||
scale: clamp(current.scale * scaleFactor, 0.45, 6),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
const handleMappingPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
const handleMappingPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
return;
|
return;
|
||||||
@@ -2334,6 +2373,40 @@ export function VoxelizationMappingView({
|
|||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const renderOverlayLoadProgress = (tone: 'dark' | 'light') => {
|
||||||
|
if (!overlayLoadState.loading || !overlayLoadState.total) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = Math.round((overlayLoadState.loaded / overlayLoadState.total) * 100);
|
||||||
|
const isDark = tone === 'dark';
|
||||||
|
return (
|
||||||
|
<div className={`pointer-events-none absolute left-4 top-4 z-20 w-64 rounded-xl border px-3 py-2 shadow-lg backdrop-blur-md ${
|
||||||
|
isDark
|
||||||
|
? 'border-white/10 bg-black/70 text-white'
|
||||||
|
: 'border-slate-200 bg-white/90 text-slate-700'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between gap-3 text-[10px] font-bold">
|
||||||
|
<span className={`min-w-0 truncate ${isDark ? 'text-cyan-100' : 'text-cyan-700'}`}>
|
||||||
|
构件层级加载中
|
||||||
|
</span>
|
||||||
|
<span className={`font-mono ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
|
||||||
|
{overlayLoadState.loaded}/{overlayLoadState.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`mt-2 h-1.5 overflow-hidden rounded-full ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-cyan-400 transition-[width] duration-200"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`mt-1 flex items-center justify-between gap-3 text-[9px] font-bold ${isDark ? 'text-white/45' : 'text-slate-400'}`}>
|
||||||
|
<span className="min-w-0 truncate">{overlayLoadState.phase || overlayStatus}</span>
|
||||||
|
<span className="font-mono">{percent}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
const renderOverlaySummary = (placement: 'bottom' | 'side') => (
|
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={`${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'}`}>
|
<div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}>
|
||||||
@@ -2389,8 +2462,8 @@ export function VoxelizationMappingView({
|
|||||||
className="flex min-h-0 flex-col"
|
className="flex min-h-0 flex-col"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`relative min-h-0 flex-1 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
|
ref={mappingViewportRef}
|
||||||
onWheel={handleMappingWheel}
|
className={`relative min-h-0 flex-1 touch-none overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
onPointerDown={handleMappingPointerDown}
|
onPointerDown={handleMappingPointerDown}
|
||||||
onPointerMove={handleMappingPointerMove}
|
onPointerMove={handleMappingPointerMove}
|
||||||
onPointerUp={stopMappingPointerDrag}
|
onPointerUp={stopMappingPointerDrag}
|
||||||
@@ -2412,6 +2485,7 @@ export function VoxelizationMappingView({
|
|||||||
{dicomStatus}
|
{dicomStatus}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{renderOverlayLoadProgress('dark')}
|
||||||
<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">
|
<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="text-[9px] font-bold text-white/45">DICOM 切片位置</p>
|
||||||
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
|
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
|
||||||
@@ -2469,8 +2543,8 @@ export function VoxelizationMappingView({
|
|||||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
|
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
|
||||||
<div className="flex min-h-0 flex-col">
|
<div className="flex min-h-0 flex-col">
|
||||||
<div
|
<div
|
||||||
className={`relative min-h-0 flex-1 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
|
ref={mappingViewportRef}
|
||||||
onWheel={handleMappingWheel}
|
className={`relative min-h-0 flex-1 touch-none overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
onPointerDown={handleMappingPointerDown}
|
onPointerDown={handleMappingPointerDown}
|
||||||
onPointerMove={handleMappingPointerMove}
|
onPointerMove={handleMappingPointerMove}
|
||||||
onPointerUp={stopMappingPointerDrag}
|
onPointerUp={stopMappingPointerDrag}
|
||||||
@@ -2492,6 +2566,7 @@ export function VoxelizationMappingView({
|
|||||||
{dicomStatus}
|
{dicomStatus}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{renderOverlayLoadProgress('dark')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-slate-100 bg-white px-4 py-3">
|
<div className="border-t border-slate-100 bg-white px-4 py-3">
|
||||||
@@ -3910,19 +3985,14 @@ export default function ReverseWorkspace({
|
|||||||
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<button
|
<button
|
||||||
onMouseDown={(event) => {
|
onPointerDown={() => {
|
||||||
event.preventDefault();
|
|
||||||
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
|
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
|
||||||
}}
|
}}
|
||||||
onMouseUp={stopPoseRepeat}
|
onPointerUp={stopPoseRepeat}
|
||||||
onMouseLeave={stopPoseRepeat}
|
onPointerLeave={stopPoseRepeat}
|
||||||
onTouchStart={(event) => {
|
onPointerCancel={stopPoseRepeat}
|
||||||
event.preventDefault();
|
|
||||||
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
|
|
||||||
}}
|
|
||||||
onTouchEnd={stopPoseRepeat}
|
|
||||||
onClick={() => nudgeModelPose(item.key, -poseStepConfig[item.key].step)}
|
onClick={() => nudgeModelPose(item.key, -poseStepConfig[item.key].step)}
|
||||||
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
className="h-6 touch-none rounded-md border border-slate-100 bg-white text-[9px] font-bold text-slate-500 shadow-sm hover:bg-blue-50 hover:text-blue-600"
|
||||||
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].minus}`}
|
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].minus}`}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
@@ -3937,19 +4007,14 @@ export default function ReverseWorkspace({
|
|||||||
className="accent-blue-600"
|
className="accent-blue-600"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onMouseDown={(event) => {
|
onPointerDown={() => {
|
||||||
event.preventDefault();
|
|
||||||
startPoseRepeat(item.key, poseStepConfig[item.key].step);
|
startPoseRepeat(item.key, poseStepConfig[item.key].step);
|
||||||
}}
|
}}
|
||||||
onMouseUp={stopPoseRepeat}
|
onPointerUp={stopPoseRepeat}
|
||||||
onMouseLeave={stopPoseRepeat}
|
onPointerLeave={stopPoseRepeat}
|
||||||
onTouchStart={(event) => {
|
onPointerCancel={stopPoseRepeat}
|
||||||
event.preventDefault();
|
|
||||||
startPoseRepeat(item.key, poseStepConfig[item.key].step);
|
|
||||||
}}
|
|
||||||
onTouchEnd={stopPoseRepeat}
|
|
||||||
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].step)}
|
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].step)}
|
||||||
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
className="h-6 touch-none rounded-md border border-slate-100 bg-white text-[9px] font-bold text-slate-500 shadow-sm hover:bg-blue-50 hover:text-blue-600"
|
||||||
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].plus}`}
|
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].plus}`}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
|
|||||||
53
工程分析/实现方案-2026-05-24-17-29-17.md
Normal file
53
工程分析/实现方案-2026-05-24-17-29-17.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 实现方案-2026-05-24-17-29-17
|
||||||
|
|
||||||
|
## 实现方案文档路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-24-17-29-17.md`
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
- 为逆向分割映射视图增加可见 STL 构件加载进度条。
|
||||||
|
- 修正 DICOM/映射画布滚轮缩放和触摸操作中的 passive event listener 警告。
|
||||||
|
- 同步 Docker 部署说明与本次交互修正。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `Docker部署/README.md`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
- 在逆向分割映射视图中新增 overlay 加载状态,记录 `loading/loaded/total/phase`。
|
||||||
|
- 可见构件列表变化时清空旧预览并按构件逐个更新加载进度;加载完成后再绘制最新 overlay。
|
||||||
|
- 将需要阻止页面滚动的 `wheel` 缩放改为原生 `addEventListener('wheel', ..., { passive: false })`,避免 React 合成事件被浏览器按 passive 处理时产生告警。
|
||||||
|
- 移除按钮触摸开始阶段不必要的 `event.preventDefault()`,用 `touch-action` 类维持操作稳定性。
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 定位 `preventDefault` 和 `onWheel/onTouchStart` 使用点。
|
||||||
|
2. 为项目库 DICOM 画布和逆向映射画布改造 wheel 事件监听。
|
||||||
|
3. 为逆向映射视图增加加载进度状态和 UI。
|
||||||
|
4. 更新 Docker 部署说明。
|
||||||
|
5. 执行类型检查、构建、部署和访问验证。
|
||||||
|
6. 提交并推送到 Gitea。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 新增进度条只依赖现有 STL preview API,不改变接口契约。
|
||||||
|
- 如 wheel 原生监听出现兼容问题,可回退到 React `onWheel` 但必须移除 `preventDefault` 或继续保持非被动监听。
|
||||||
|
- 回滚时可撤销本次 commit,重新构建并部署上一版本。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- 2 个前端组件文件。
|
||||||
|
- 1 个 Docker 部署说明文件。
|
||||||
|
- 3 个工程分析当次文档。
|
||||||
|
- 1 个经验记录追加。
|
||||||
|
|
||||||
|
## 提交与部署策略
|
||||||
|
|
||||||
|
- Commit message 使用 `2026-05-24-17-29-17 增加构件加载进度并修正滚轮事件`。
|
||||||
|
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`,使用生产模式启动服务。
|
||||||
|
- 验证本机和公网入口可访问。
|
||||||
46
工程分析/测试方案-2026-05-24-17-29-17.md
Normal file
46
工程分析/测试方案-2026-05-24-17-29-17.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 测试方案-2026-05-24-17-29-17
|
||||||
|
|
||||||
|
## 测试方案文档路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-24-17-29-17.md`
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 执行 `cd WebSite && npm run lint`,确认 TypeScript 类型检查通过。
|
||||||
|
- 搜索 `preventDefault` 相关调用,确认需要阻止页面滚动的场景使用非被动原生 wheel 监听,不再在 passive-prone touch/wheel 合成事件中调用。
|
||||||
|
|
||||||
|
## 构建检查
|
||||||
|
|
||||||
|
- 执行 `cd WebSite && npm run build`。
|
||||||
|
- 确认 Vite 生产构建成功,前端 bundle 可生成。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 逆向工作区切换构件层级可见性时,映射视图区出现加载进度条,完成后显示当前可见构件 overlay。
|
||||||
|
- 项目库 DICOM 影像保留滚轮缩放、拖拽移动、左转、右转和位置重置能力。
|
||||||
|
- 逆向分割映射视图保留滚轮缩放、拖拽移动和位置重置能力。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- 构件隐藏到 0 个可见项时不显示加载进度,并提示当前没有可见 STL 构件。
|
||||||
|
- 大 STL 构件加载过程中不能用旧预览覆盖新可见状态。
|
||||||
|
- 本次不改动 DICOM/STL 原始数据和 NIfTI 导出算法,仅验证显示反馈不影响已有流程。
|
||||||
|
|
||||||
|
## 部署验证
|
||||||
|
|
||||||
|
- 重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||||
|
- 验证 `http://127.0.0.1:4000/api/health`。
|
||||||
|
- 验证 `http://127.0.0.1:4000/`。
|
||||||
|
- 验证 `https://revoxel.huijutec.cn/api/health` 与 `https://revoxel.huijutec.cn/`。
|
||||||
|
|
||||||
|
## Git/Gitea 备份验证
|
||||||
|
|
||||||
|
- 仅暂存本次相关前端、Docker 文档和工程分析文件。
|
||||||
|
- 提交 message 包含 `2026-05-24-17-29-17`。
|
||||||
|
- 推送到 Gitea `main` 后检查本地分支与远端同步。
|
||||||
|
|
||||||
|
## 风险与回归关注点
|
||||||
|
|
||||||
|
- 浏览器控制台仍可能有第三方库内部 passive 事件提示,需要通过源码搜索和实际交互继续定位。
|
||||||
|
- 加载进度 UI 不能遮挡核心 DICOM 区域或造成布局跳动。
|
||||||
|
- 原生 wheel 监听要在组件卸载时清理,防止重复绑定。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1729,3 +1729,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
凡是导出“当前可见”这类 UI 状态,不能只依赖历史保存结果;后端应以项目当前状态为准,前端最好显式携带本次导出的关键状态,防止异步保存延迟。二维 overlay 如果用于判断实体形状,必须确认 STL 预览不是抽样缺面;对超过预览上限的大模型,应按可见构件高精度加载或在界面提示。细血管、胆管等管状结构应解释为“天然多小截面/窄带”,不要把所有结构都承诺成单一大实体块。
|
凡是导出“当前可见”这类 UI 状态,不能只依赖历史保存结果;后端应以项目当前状态为准,前端最好显式携带本次导出的关键状态,防止异步保存延迟。二维 overlay 如果用于判断实体形状,必须确认 STL 预览不是抽样缺面;对超过预览上限的大模型,应按可见构件高精度加载或在界面提示。细血管、胆管等管状结构应解释为“天然多小截面/窄带”,不要把所有结构都承诺成单一大实体块。
|
||||||
|
|
||||||
|
## 2026-05-24-17-29-17 构件层级切换要有加载反馈并避免 passive 事件告警
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户反馈调整逆向工作区“构件层级”时可能出现暂时不显示或看起来像 bug,同时浏览器控制台反复打印 `Unable to preventDefault inside passive event listener invocation`。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
逆向分割映射视图为了修正抽样缺面,已经改成按当前可见构件高精度加载 STL preview。构件眼睛切换后,新 preview 在网络和前端缓存返回前,旧 overlay 被清空或尚未绘制,界面没有明确进度反馈。另一个问题是项目库 DICOM 画布和逆向映射画布仍使用 React `onWheel` 调用 `preventDefault`,部分浏览器会把 wheel/touch 合成事件按 passive 处理;按钮 `onTouchStart` 中的 `preventDefault` 也会增加同类告警风险。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
为逆向分割映射视图新增 `overlayLoadState`,在可见 STL 构件列表变化时清空旧 preview、显示 `loaded/total` 进度条,并在每个构件 preview 成功或失败后更新进度,全部完成后再绘制最新 overlay。项目库 DICOM 画布和逆向映射画布改为原生 `addEventListener('wheel', ..., { passive: false })`,保留滚轮缩放和阻止页面滚动能力。长按步进按钮改用 pointer 事件和 `touch-none`,移除 touch 合成事件里的 `preventDefault`。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
后续只要某个 UI 操作会触发异步加载大 STL、DICOM 或导出预计算,都应提供明确加载状态、进度和空状态,避免用户把正常等待理解为显示故障。需要阻止滚轮默认滚动时,应使用原生非被动 wheel 监听并在卸载时清理;触摸按钮优先使用 pointer 事件和 `touch-action` 控制,不要在 passive-prone 的 touch 合成事件中调用 `preventDefault`。
|
||||||
|
|||||||
44
工程分析/需求分析-2026-05-24-17-29-17.md
Normal file
44
工程分析/需求分析-2026-05-24-17-29-17.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 需求分析-2026-05-24-17-29-17
|
||||||
|
|
||||||
|
## 开始时间
|
||||||
|
|
||||||
|
2026-05-24-17-29-17
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户反馈在逆向工作区调整“构件层级”时,可能出现构件显示异常或暂时不显示;如果处于加载中,应在界面显示加载进度条。同时浏览器控制台反复输出 `Unable to preventDefault inside passive event listener invocation`,需要排查并修正相关事件监听。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 提升构件层级可见性切换后的反馈,避免高精度 STL 预览加载期间被误判为显示故障。
|
||||||
|
- 修正滚轮或触摸事件中被动监听器内调用 `preventDefault` 的浏览器警告。
|
||||||
|
- 保持现有逆向分割映射视图、项目库 DICOM 操作和部署方式兼容。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:用户在构件层级中切换眼睛、透明度或可见构件后触发高精度模型预览重新加载;用户在 DICOM/映射画布上滚轮缩放或拖动。
|
||||||
|
- 输出:加载可见 STL 构件时显示进度条与数量;加载完成后恢复正常 overlay;控制台不再因被动事件监听器反复打印 `preventDefault` 警告。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`:逆向分割映射视图、构件预览加载流程、滚轮缩放事件。
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`:项目库 DICOM 画布滚轮缩放与触摸重复按钮事件。
|
||||||
|
- `Docker部署/README.md`:同步记录本次前端交互与部署包能力变化。
|
||||||
|
- `工程分析/经验记录.md`:沉淀本次事件监听和加载反馈经验。
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- 必须继续按当前 `tmux` 会话 `revoxelseg-dicom` 和 `npm run serve -- --host 0.0.0.0 --port 4000` 方式部署验证。
|
||||||
|
- 不改变 STL 到 Label Map 的体素化算法语义,仅改善加载反馈与事件监听方式。
|
||||||
|
- 不能提交运行态数据、导出包、医学原始数据或无关文件。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 高精度可见构件加载可能较快或较慢,进度状态要处理竞态,避免旧加载结果覆盖新可见状态。
|
||||||
|
- 去除或迁移 `preventDefault` 时不能破坏滚轮缩放、拖拽平移、长按步进等既有交互。
|
||||||
|
- 进度条必须只在加载中展示,避免常驻遮挡医学影像区域。
|
||||||
|
|
||||||
|
## 待确认问题或默认假设
|
||||||
|
|
||||||
|
- 默认假设用户所说的“加载”主要指逆向分割映射视图为当前可见构件重新拉取 STL preview 的阶段。
|
||||||
|
- 默认假设控制台警告来自 React `onWheel` 或触摸事件中的 `preventDefault`,优先改为原生非被动 wheel 监听和移除不必要的 touch `preventDefault`。
|
||||||
Reference in New Issue
Block a user