Browse Source

fix: 修复体位选择切换时的无限渲染循环

- 移除ViewerContainer中导致无限循环的useEffect
- 添加选中状态检查避免重复dispatch
- 修改1x1布局选择逻辑和图像显示逻辑

改动文件:
- src/pages/view/components/ViewerContainer.tsx
- docs/实现/体位选择与Viewer同步修复文档.md
sw 1 day ago
parent
commit
6864d8a79f

+ 167 - 0
docs/实现/体位选择与Viewer同步修复文档.md

@@ -0,0 +1,167 @@
+# 体位选择与Viewer同步修复文档
+
+## 问题描述
+在使用图像处理页面时,切换到1x1布局后,viewer总是显示第一个图像,而非选中体位对应图像。同时,发现切换体位时会导致无限渲染循环错误。
+
+### 表现症状
+1. 切换到1x1布局时,总是选择第一个图像
+2. 体位选择时出现无限渲染循环:"Maximum update depth exceeded"
+3. 1x1布局无法正确显示对应选中的体位图像
+
+## 根本原因分析
+
+### 1. ViewerContainer.tsx 无限循环问题
+- **现象**:renderGrid函数中在渲染期间dispatch状态变化
+- **问题**:
+  - useEffect监听selectedBodyPosition和gridLayout,每次变化时调用renderGrid()
+  - renderGrid()中1x1 case内dispatch toggleViewerSelection
+  - dispatch导致组件重新渲染,又触发useEffect → renderGrid() → dispatch → ...
+- **结果**:无限渲染循环,导致应用卡死
+
+### 2. Viewer选择逻辑问题
+- **现象**:1x1布局硬编码选择allViewerUrls[0]
+- **问题**:未与selectedBodyPosition关联
+- **结果**:切换布局时无法保持选中体位的高亮状态,且不显示对应图像
+
+### 3. 显示逻辑问题
+- **现象**:renderViewers(0, 1) 总是显示图像数组的第一个元素
+- **问题**:未利用selectedBodyPosition确定显示的图像索引
+- **结果**:1x1布局锁定了第一个图像,无法同步到选中体位
+
+### 4. 数据流分析
+```
+BodyPositionList 点击 → selectedBodyPosition 更新
+       ↓
+ViewerContainer useEffect触发 → renderGrid() → dispatch → 重新渲染
+       ↓
+无限循环异常
+       ↓
+布局切换 → 固定选择第一个图像
+```
+
+## 修复方案
+
+### 修改1: 移除引发无限循环的useEffect
+**文件**: `src/pages/view/components/ViewerContainer.tsx`
+
+```typescript
+// 移除以下useEffect,因为它会导致renderGrid在每次渲染时被调用
+// 删除此useEffect以避免循环
+```
+
+**变更点**:
+- 删除 `useEffect(() => { renderGrid(); }, [selectedBodyPosition, gridLayout])`
+- 避免在渲染前调用renderGrid导致的循环
+
+### 修改2: 添加状态检查,避免重复dispatch
+**文件**: `src/pages/view/components/ViewerContainer.tsx`
+
+在1x1 case中添加状态检查逻辑:
+
+```typescript
+case '1x1': {
+  // 根据 selectedBodyPosition 找到对应URL
+  let urlToSelect = allViewerUrls[0]; // fallback
+  
+  if (selectedBodyPosition?.sop_instance_uid) {
+    const correspondingUrl = getDcmImageUrl(selectedBodyPosition.sop_instance_uid);
+    const urlIndex = allViewerUrls.indexOf(correspondingUrl);
+    if (urlIndex !== -1) {
+      urlToSelect = allViewerUrls[urlIndex];
+    }
+  }
+
+  // 检查当前选中状态,避免重复dispatch导致的无限循环
+  const currentSelected = selectedViewerUrls[0];
+  if (currentSelected !== urlToSelect) {
+    if (currentSelected) {
+      dispatch(toggleViewerSelection(currentSelected)); // 取消选中当前
+    }
+    dispatch(toggleViewerSelection(urlToSelect)); // 选中新的
+  }
+
+  // 修改renderViewers调用,根据selectedBodyPosition确定显示的图像索引
+  let viewerIndex = 0;
+  if (selectedBodyPosition?.sop_instance_uid) {
+    const correspondingUrl = getDcmImageUrl(selectedBodyPosition.sop_instance_uid);
+    const index = imageUrls.indexOf(correspondingUrl);
+    if (index !== -1) {
+      viewerIndex = index;
+    }
+  }
+  
+  return (
+    <div className="h-full w-full" style={{ display: 'grid', gridTemplateColumns: '1fr' }}>
+      {renderViewers(viewerIndex, viewerIndex + 1)}
+    </div>
+  );
+}
+```
+
+**变更点**:
+- 添加状态检查:只有当选中状态不同时才dispatch,避免重复触发
+- 修改renderViewers:根据selectedBodyPosition找到对应图像索引,确保1x1布局显示正确的图像
+- 导入 `getDcmImageUrl` 函数用于URL匹配
+
+## 技术细节
+
+### URL映射原理
+- `sop_instance_uid` → `getDcmImageUrl()` → 生成唯一URL
+- `allViewerUrls`/`imageUrls` 数组查找 → 找到索引 → 选择/display对应viewer/图像
+- 确保了 sop_instance_uid ↔ image URL 的一一对应
+
+### 边界情况处理
+- 未选择体位:使用默认索引0(通常是第一个曝光图像)
+- 体位未曝光:如果某一索引对应未曝光URL,则渲染为空,但实际多是曝光体位
+- 多个曝光体位:1x1布局会根据选中体位显示对应的单个图像
+
+### 状态更新链路
+```
+点击体位 → selectedBodyPosition change → ViewerContainer 检测到变化
+                         ↓
+renderGrid re-render → 重新计算选择和显示逻辑
+                         ↓
+1x1布局 → 选择正确viewer + 显示对应图像
+```
+
+## 测试验证
+
+### 预期行为
+1. **避免无限循环**:体位选择不再出现"Maximum update depth exceeded"错误
+2. **选择同步**:切换到1x1时,高亮选中体位对应的viewer
+3. **图像显示**:1x1布局显示selectedBodyPosition对应的图像,而非第一个
+4. **边缘情况**:未选择体位时,使用默认fallback (索引0)
+
+### 控制台日志
+- `[ViewerContainer]` - viewer容器渲染日志
+- SOP Instance UID匹配检查 - 确认URL映射是否正确
+- 选中状态变化日志 - 验证dispatch逻辑
+
+## 实现优势
+
+### 修复循环问题
+- 移除了不当的useEffect调用,避免渲染期间dispatch
+- 添加条件检查,防止重复状态修改触发循环
+- 维持单一渲染循环,提高应用稳定性
+
+### 保持架构一致性
+- 利用现有Redux状态管理系统
+- 不新增事件或全局变量
+- 使用组件渲染 Circ响应式更新
+
+### 性能考虑
+- 数组操作查找时间与曝光体位数相关
+- 只在必要时执行状态修改,避免多余render
+- 避免频繁的DOM更新
+
+### 维护性
+- 逻辑集中于renderGrid函数中,便于排查
+- 清晰的状态流转,有助于理解组件行为
+- 边界情况处理明确,易于扩展
+
+## 总结
+通过两个关键修复解决了ViewerContainer组件的问题:
+- **修复无限循环**:移除useEffect引起的渲染循环,添加条件dispatch防止无限调用
+- **完善选择逻辑**:根据selectedBodyPosition智能选择viewer并正确显示对应的图像
+
+修复确保了体位选择时应用的稳定性和正确显示,为用户提供可靠的图像查看体验。若需要进一步优化为仅显示选中图像,可考虑修改ImageControl组件。

+ 44 - 18
src/pages/view/components/ViewerContainer.tsx

@@ -33,6 +33,7 @@ import StackViewer, {
   activateHipDorsalCoverageMeasurement,
 } from './viewers/stack.image.viewer';
 import { useSelector, useDispatch } from 'react-redux';
+import { getDcmImageUrl } from '@/API/bodyPosition';
 import store, { RootState } from '@/states/store';
 import { clearAction } from '@/states/view/functionAreaSlice';
 import {
@@ -159,7 +160,7 @@ interface ViewerContainerProps {
 
 const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
   console.log(`[ViewerContainer] 新渲染 imageUrls:`, imageUrls);
-  
+
   // 从 Redux 获取状态
   const gridLayout = useSelector(selectGridLayout);
   const selectedViewerUrls = useSelector(selectSelectedViewers);
@@ -171,7 +172,7 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
     (state: RootState) => state.bodyPositionList.selectedBodyPosition
   );
 
-  
+
   console.log(`[ViewerContainer] rerendered]`);
 
   // 当 imageUrls 改变时,更新 Redux 中的 allViewers
@@ -215,7 +216,7 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
     selectedViewportIds: string[]
   ) => {
     console.log(`开始${config.logPrefix}测量`);
-    
+
     const viewportIds = selectedViewportIds.length > 0
       ? selectedViewportIds
       : Array.from({ length: getVisibleViewportCount() }, (_, i) => `viewport-${i}`);
@@ -253,9 +254,7 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
     }
   };
 
-  useEffect(() => {
-    renderGrid();
-  }, [selectedBodyPosition, gridLayout]);
+
 
   useEffect(() => {
     if (action) {
@@ -263,7 +262,7 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
         `[ViewerContainer] 处理功能操作: ${action}, selectedViewers:`,
         selectedViewerUrls
       );
-      
+
       // 将选中的 imageUrl 转换为 viewportId
       const selectedViewportIds = selectedViewerUrls
         .map(getViewportIdByUrl)
@@ -563,7 +562,7 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
                 );
               }
             });
-          } 
+          }
           console.log('Activating Angle Measurement from MeasurementPanel');
           break;
 
@@ -608,16 +607,43 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
     console.log('Rendering  layout', gridLayout);
     switch (gridLayout) {
       case '1x1': {
-        //变成单分格时,自动选中第一个
-        dispatch(toggleViewerSelection(allViewerUrls[0]));
-          return (
-            <div
-              className="h-full w-full"
-              style={{ display: 'grid', gridTemplateColumns: '1fr' }}
-            >
-              {renderViewers(0, 1)}
-            </div>
-          );
+        // 变成单分格时,选中对应 selectedBodyPosition 的 URL
+        let urlToSelect = allViewerUrls[0]; // 默认选第一个作为fallback
+
+        if (selectedBodyPosition?.sop_instance_uid) {
+          const correspondingUrl = getDcmImageUrl(selectedBodyPosition.sop_instance_uid);
+          const urlIndex = allViewerUrls.indexOf(correspondingUrl);
+          if (urlIndex !== -1) {
+            urlToSelect = allViewerUrls[urlIndex];
+          }
+        }
+
+        // 检查当前选中状态,避免无限循环
+        const currentSelected = selectedViewerUrls[0];
+        if (currentSelected !== urlToSelect) {
+          if (currentSelected) {
+            dispatch(toggleViewerSelection(currentSelected)); // 取消选中当前
+          }
+          dispatch(toggleViewerSelection(urlToSelect)); // 选中新的
+        }
+        // 找到体位列表中选中体位对应的url
+        let viewerIndex = 0;
+        if (selectedBodyPosition?.sop_instance_uid) {
+          const correspondingUrl = getDcmImageUrl(selectedBodyPosition.sop_instance_uid);
+          const urlIndex = imageUrls.indexOf(correspondingUrl);
+          if (urlIndex !== -1) {
+            viewerIndex = urlIndex;
+          }
+        }
+
+        return (
+          <div
+            className="h-full w-full"
+            style={{ display: 'grid', gridTemplateColumns: '1fr' }}
+          >
+            {renderViewers(viewerIndex, viewerIndex + 1)}
+          </div>
+        );
         break;
       }
       case '1x2':