Browse Source

feat: 实现滑动参数调节面板实时预览功能并优化图像处理流程

- 在 imageActions.ts 中新增 buildProcessedDcmUrl API 构建处理图像URL
- 在 viewerContainerSlice.ts 中添加 viewerUrlMap 状态管理和 updateViewerUrl action
- 在 ViewerContainer.tsx 中实现 getActualUrl 辅助函数和URL映射逻辑
- 在 SliderAdjustmentPanel.tsx 中修改预览逻辑,使用URL构建而非API调用
- 在 stack.image.viewer.tsx 中添加图像URL变化监听和自动重新加载
- 更新实现文档,详细说明实时预览的数据流和关键设计

改动文件:
- src/API/imageActions.ts
- src/states/view/viewerContainerSlice.ts
- src/pages/view/components/ViewerContainer.tsx
- src/pages/view/components/SliderAdjustmentPanel.tsx
- src/pages/view/components/viewers/stack.image.viewer.tsx
- docs/实现/滑动参数调节面板功能.md
dengdx 17 hours ago
parent
commit
da5c67af85

+ 46 - 9
docs/实现/滑动参数调节面板功能.md

@@ -347,19 +347,56 @@ API调用 POST /proc (saveImageProcessingParams)
 - ✅ 完成UI组件开发
 - ✅ 完成面板集成
 - ✅ 实现实时预览API调用逻辑
-- ⏳ 待完成:获取当前图像ID逻辑
-- ⏳ 待完成:Cornerstone图像刷新实现
-- ⏳ 待完成:Blob URL内存管理
+- ✅ 获取当前图像ID逻辑
+- ✅ URL构建和更新机制
+- ✅ ViewerContainer URL映射管理
+- ✅ Cornerstone图像刷新实现
 - ⏳ 待完成:功能测试
 
 ### 实时预览实现细节
 **已实现**:
 1. 新增 `getProcessedDcm` API - 获取处理后的dcm Blob
-2. 修改滑块逻辑 - 调用预览API而非保存API
-3. 区分前端参数和后端参数 - 只有后端参数触发预览
-4. 防抖机制 - 500ms延迟避免频繁请求
+2. 新增 `buildProcessedDcmUrl` API - 构建处理后的dcm URL(不发送请求)
+3. 修改滑块逻辑 - 构建URL而非调用预览API
+4. 区分前端参数和后端参数 - 只有后端参数触发预览
+5. 防抖机制 - 500ms延迟避免频繁更新
+6. ViewerContainer URL映射 - 管理图像URL和处理参数
+7. URL更新机制 - 通过updateViewerUrl触发重新渲染
+8. 修正updateViewerUrl action - 只更新viewerUrlMap,保持原始URL不变
+9. ViewerContainer getActualUrl辅助函数 - 根据映射获取实际URL
+10. StackViewer监听imageUrls变化 - 自动重新加载图像
+
+**数据流**:
+```
+用户调整滑块参数
+  ↓
+SliderAdjustmentPanel - updateParameter
+  ↓
+防抖500ms
+  ↓
+buildProcessedDcmUrl - 构建带参数的URL
+  ↓
+updateViewerUrl - 更新viewerUrlMap[originalUrl] = processedUrl
+  ↓
+ViewerContainer重新渲染
+  ↓
+getActualUrl - 从viewerUrlMap获取处理后的URL
+  ↓
+StackViewer接收新的imageUrls
+  ↓
+useEffect监听imageUrls变化
+  ↓
+viewport.setStack(imageUrls) - 重新加载图像
+  ↓
+viewport.render() - 渲染新图像
+```
+
+**关键设计**:
+- **数据分离**: allViewers和selectedViewers保存原始URL,viewerUrlMap保存URL映射
+- **单向数据流**: 状态变化 → URL映射更新 → 组件重新渲染 → 图像重新加载
+- **易于恢复**: 清除viewerUrlMap的条目即可恢复原始图像
+- **性能优化**: 防抖机制避免频繁更新,只有URL实际变化时才重新加载
 
 **待实现**:
-1. Blob URL → Cornerstone 图像刷新
-2. Blob URL 内存管理(revoke机制)
-3. 获取当前图像ID的完整逻辑
+1. 功能测试和验证
+2. 错误处理和用户反馈优化

+ 31 - 0
src/API/imageActions.ts

@@ -1,4 +1,5 @@
 import axiosInstance from './interceptor';
+import { API_BASE_URL } from './config';
 
 /**
  * 发送图像请求参数
@@ -249,6 +250,36 @@ export interface GetProcessedDcmRequest {
   noise: string;
 }
 
+/**
+ * 构建处理后的dcm URL(不发送请求)
+ * @param sopInstanceUid 图像实例 UID (SOP Instance UID)
+ * @param params 图像处理参数
+ * @returns 处理后的dcm URL字符串
+ * @example
+ * ```typescript
+ * const url = buildProcessedDcmUrl('1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668671', {
+ *   contrast: '5.0',
+ *   detail: '9.0',
+ *   latitude: '25.0',
+ *   noise: '12.0'
+ * });
+ * console.log('处理后图像URL:', url);
+ * ```
+ */
+export const buildProcessedDcmUrl = (
+  sopInstanceUid: string,
+  params: GetProcessedDcmRequest
+): string => {
+  const queryParams = new URLSearchParams({
+    contrast: params.contrast,
+    detail: params.detail,
+    latitude: params.latitude,
+    noise: params.noise,
+  });
+  return `${API_BASE_URL}pub/image/${sopInstanceUid}/proc?${queryParams.toString()}`;//暂时使用公开的访问方式,不需要token
+  // return `${API_BASE_URL}auth/image/${sopInstanceUid}/proc?${queryParams.toString()}`;
+};
+
 /**
  * 保存图像处理参数响应类型
  */

+ 23 - 16
src/pages/view/components/SliderAdjustmentPanel.tsx

@@ -22,7 +22,8 @@ import {
   selectError,
   selectCurrentImageId,
 } from '../../../states/view/sliderAdjustmentPanelSlice';
-import { getProcessedDcm } from '../../../API/imageActions';
+import { buildProcessedDcmUrl } from '../../../API/imageActions';
+import { updateViewerUrl } from '../../../states/view/viewerContainerSlice';
 import { PARAMETER_RANGES, ALGORITHM_OPTIONS } from '../../../domain/processingPresets';
 import { LUT_OPTIONS } from '../../../domain/lutConfig';
 import type { ProcessingStyle, LUTType, FullProcessingParams } from '../../../types/imageProcessing';
@@ -88,7 +89,7 @@ const SliderAdjustmentPanel = () => {
   };
   
   /**
-   * 防抖获取预览图像
+   * 防抖更新预览图像URL
    */
   const debouncedPreview = useCallback(
     (params: FullProcessingParams) => {
@@ -103,36 +104,42 @@ const SliderAdjustmentPanel = () => {
       }
       
       // 设置新的定时器
-      saveTimerRef.current = setTimeout(async () => {
+      saveTimerRef.current = setTimeout(() => {
         if (currentImageId) {
           try {
-            // 调用GET API获取处理后的dcm
-            const dcmBlob = await getProcessedDcm(currentImageId, {
+            // 获取原始URL
+            const dcmUrls = store.getState().viewerContainer.selectedViewers;
+            if (dcmUrls.length !== 1) {
+              console.error('没有选中的图像或者数量大于1,无法更新预览');
+              return;
+            }
+            const originalUrl = dcmUrls[0];
+            
+            // 构建处理后的dcm URL(带参数)
+            const processedUrl = buildProcessedDcmUrl(currentImageId, {
               contrast: params.contrast.toString(),
               detail: params.detail.toString(),
               latitude: params.latitude.toString(),
               noise: params.noise.toString(),
             });
             
-            // 创建Blob URL
-            const blobUrl = URL.createObjectURL(dcmBlob);
+            // 更新viewer URL以触发重新渲染
+            dispatch(updateViewerUrl({
+              originalUrl,
+              newUrl: `dicomweb:${processedUrl}`,
+            }));
             
-            // TODO: 这里需要刷新Cornerstone显示
-            // 目前先在控制台输出,后续需要实现图像刷新逻辑
-            console.log('预览图像URL:', blobUrl);
+            console.log('已更新预览图像URL:', processedUrl);
             console.log('参数:', params);
             
-            // 提示:需要在这里更新Viewer显示
-            message.info('预览图像已生成(待实现图像刷新)');
-            
           } catch (error) {
-            console.error('获取预览图像失败:', error);
-            message.error('获取预览图像失败');
+            console.error('更新预览图像失败:', error);
+            message.error('更新预览图像失败');
           }
         }
       }, 500); // 500ms 防抖延迟
     },
-    [currentImageId, isInitialLoad]
+    [currentImageId, isInitialLoad, dispatch]
   );
   
   /**

+ 27 - 15
src/pages/view/components/ViewerContainer.tsx

@@ -40,6 +40,7 @@ import {
   selectGridLayout,
   selectSelectedViewers,
   selectAllViewerUrls,
+  selectViewerUrlMap,
   setGridLayout as setGridLayoutAction,
   setAllViewers,
   toggleViewerSelection,
@@ -142,7 +143,7 @@ const setup = () => {
     beforeSend: (xhr, imageId, defaultHeaders) => {
       return {
         ...defaultHeaders,
-        Authorization: `Bearer ${token}`,
+        Authorization: `Bearer ${token}`,//todo 此处可能初始化过早,使用viewport访问dcm时,在请求标头authorization字段中会出现空的token
         Language: 'en',
         Product: 'DROS',
         Source: 'Electron',
@@ -165,6 +166,7 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
   const gridLayout = useSelector(selectGridLayout);
   const selectedViewerUrls = useSelector(selectSelectedViewers);
   const allViewerUrls = useSelector(selectAllViewerUrls);
+  const viewerUrlMap = useSelector(selectViewerUrlMap);
   const action = useSelector((state: RootState) => state.functionArea.action);
   const measurementAction = useSelector(selectCurrentMeasurementAction);
   const dispatch = useDispatch();
@@ -172,6 +174,13 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
     (state: RootState) => state.bodyPositionList.selectedBodyPosition
   );
 
+  /**
+   * 获取实际URL的辅助函数
+   * 如果URL在viewerUrlMap中有映射,返回处理后的URL;否则返回原始URL
+   */
+  const getActualUrl = (originalUrl: string): string => {
+    return viewerUrlMap[originalUrl] || originalUrl;
+  };
 
   console.log(`[ViewerContainer] rerendered]`);
 
@@ -189,21 +198,24 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
 
   const renderViewers = (start: number, end: number) => {
     console.log(`Rendering viewers from ${start} to ${end}`);
-    return imageUrls.slice(start, end).map((url, index) => (
-      <div
-        key={start + index}
-        onClick={(event) => handleSelectViewer(url, event)}
-      >
-        <StackViewer
+    return imageUrls.slice(start, end).map((originalUrl, index) => {
+      const actualUrl = getActualUrl(originalUrl);
+      return (
+        <div
           key={start + index}
-          imageIndex={0}
-          imageUrls={[url]}
-          viewportId={getViewportIdByUrl(url) as string}
-          renderingEngineId={renderingEngineId}
-          selected={selectedViewerUrls.includes(url)}
-        />
-      </div>
-    ));
+          onClick={(event) => handleSelectViewer(originalUrl, event)}
+        >
+          <StackViewer
+            key={start + index}
+            imageIndex={0}
+            imageUrls={[actualUrl]}
+            viewportId={getViewportIdByUrl(originalUrl) as string}
+            renderingEngineId={renderingEngineId}
+            selected={selectedViewerUrls.includes(originalUrl)}
+          />
+        </div>
+      );
+    });
   };
 
   /**

+ 21 - 1
src/pages/view/components/viewers/stack.image.viewer.tsx

@@ -1097,6 +1097,24 @@ const StackViewer = ({
   // const action = useSelector((state: RootState) => state.functionArea.action);
   // const dispatch = useDispatch();
 
+  // // 监听imageUrls变化并重新加载图像
+  // useEffect(() => {
+  //   const renderingEngine = cornerstone.getRenderingEngine(renderingEngineId);
+  //   if (!renderingEngine) {
+  //     return;
+  //   }
+
+  //   const viewport = renderingEngine.getViewport(viewportId) as cornerstone.Types.IStackViewport;
+  //   if (viewport && imageUrls.length > 0) {
+  //     console.log(`[StackViewer] imageUrls changed for viewport ${viewportId}, reloading...`);
+  //     viewport.setStack(imageUrls, imageIndex).then(() => {
+  //       viewport.render();
+  //     }).catch((error) => {
+  //       console.error('[StackViewer] Error reloading image stack:', error);
+  //     });
+  //   }
+  // }, [imageUrls, imageIndex, viewportId, renderingEngineId]);
+
   useEffect(() => {
     const setup = async () => {
       // // 初始化 Cornerstone
@@ -1174,7 +1192,9 @@ const StackViewer = ({
 
       // 给定一个dcm文件路径,加载并显示出来
       try {
+        console.log(`重新加载图像----开始`);
         await viewport.setStack(imageUrls, imageIndex);
+        console.log(`重新加载图像----结束`);
       } catch (error) {
         if (error instanceof Error) {
           console.error(
@@ -1189,7 +1209,7 @@ const StackViewer = ({
     };
 
     setup();
-  }, [elementRef, imageIndex, viewportId, renderingEngineId]);
+  }, [elementRef, imageIndex, viewportId, renderingEngineId,imageUrls[0]]);
 
   return (
     <div

+ 20 - 0
src/states/view/viewerContainerSlice.ts

@@ -16,6 +16,8 @@ interface ViewerContainerState {
   selectedViewers: string[];
   /** 所有 viewer 的 imageUrl 列表 */
   allViewers: string[];
+  /** 图像URL映射(包含处理参数) */
+  viewerUrlMap: Record<string, string>;
 }
 
 /**
@@ -25,6 +27,7 @@ const initialState: ViewerContainerState = {
   gridLayout: '1x1',
   selectedViewers: [],
   allViewers: [],
+  viewerUrlMap: {},
 };
 
 /**
@@ -147,6 +150,15 @@ const viewerContainerSlice = createSlice({
       state.selectedViewers = [];
     },
 
+    /**
+     * 更新单个 viewer 的 URL
+     * 只更新 viewerUrlMap,保持 allViewers 和 selectedViewers 不变
+     */
+    updateViewerUrl: (state, action: PayloadAction<{ originalUrl: string; newUrl: string }>) => {
+      const { originalUrl, newUrl } = action.payload;
+      state.viewerUrlMap[originalUrl] = newUrl;
+    },
+
     /**
      * 重置 ViewerContainer 状态
      */
@@ -154,6 +166,7 @@ const viewerContainerSlice = createSlice({
       state.gridLayout = '1x1';
       state.selectedViewers = [];
       state.allViewers = [];
+      state.viewerUrlMap = {};
     },
   },
 });
@@ -170,6 +183,7 @@ export const {
   clearSelection,
   selectAllViewers,
   deselectAllViewers,
+  updateViewerUrl,
   resetViewerContainer,
 } = viewerContainerSlice.actions;
 
@@ -232,5 +246,11 @@ export const selectVisibleViewers = (state: RootState): string[] => {
   return allViewers.slice(0, maxVisible);
 };
 
+/**
+ * 选择图像URL映射
+ */
+export const selectViewerUrlMap = (state: RootState): Record<string, string> =>
+  state.viewerContainer.viewerUrlMap;
+
 // ==================== Reducer ====================
 export default viewerContainerSlice.reducer;