2026-05-24-17-29-17 增加构件加载进度并修正滚轮事件

This commit is contained in:
2026-05-24 17:42:48 +08:00
parent d9572e6966
commit 1dcfc2a4c1
7 changed files with 299 additions and 58 deletions

View File

@@ -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` 警告。

View File

@@ -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,14 +384,27 @@ 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(() => {
event.preventDefault(); const element = viewportRef.current;
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1; if (!element) {
setViewport((current) => ({ return;
...current, }
scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)),
})); const handleWheel = (event: WheelEvent) => {
}; event.preventDefault();
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
setViewport((current) => ({
...current,
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} />

View File

@@ -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}`}
> >
+ +

View 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`,使用生产模式启动服务。
- 验证本机和公网入口可访问。

View 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 监听要在组件卸载时清理,防止重复绑定。

View File

@@ -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`

View 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`