Browse Source

使用slice管理 image viewer

sw 5 days ago
parent
commit
5260dab729

+ 113 - 164
src/pages/view/components/ViewerContainer.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, ReactElement } from 'react';
+import React, { useEffect } from 'react';
 import StackViewer, {
   activateMagnifier,
   addLMark,
@@ -25,6 +25,14 @@ import StackViewer, {
 import { useSelector, useDispatch } from 'react-redux';
 import store, { RootState } from '@/states/store';
 import { clearAction } from '@/states/view/functionAreaSlice';
+import {
+  selectGridLayout,
+  selectSelectedViewers,
+  selectAllViewerUrls,
+  setGridLayout as setGridLayoutAction,
+  setAllViewers,
+  toggleViewerSelection,
+} from '@/states/view/viewerContainerSlice';
 import {
   clearMeasurementAction,
   selectCurrentMeasurementAction,
@@ -79,255 +87,224 @@ interface ViewerContainerProps {
 
 const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
   console.log(`[ViewerContainer] 新渲染 imageUrls:`, imageUrls);
-  const [selectedViewers, setSelectedViewers] = useState<number[]>([]);
-  const [gridLayout, setGridLayout] = useState<string>('1x1');
+  
+  // 从 Redux 获取状态
+  const gridLayout = useSelector(selectGridLayout);
+  const selectedViewerUrls = useSelector(selectSelectedViewers);
+  const allViewerUrls = useSelector(selectAllViewerUrls);
   const action = useSelector((state: RootState) => state.functionArea.action);
   const measurementAction = useSelector(selectCurrentMeasurementAction);
   const dispatch = useDispatch();
   const selectedBodyPosition = useSelector(
     (state: RootState) => state.bodyPositionList.selectedBodyPosition
   );
-  const bodyPositions = useSelector(
-    (state: RootState) => state.bodyPositionList.bodyPositions
-  );
-  // eslint-disable-next-line
-  const [viewersState, setViewersState] = useState<ReactElement[]>([]);
+
+  
   console.log(`[ViewerContainer] rerendered]`);
 
+  // 当 imageUrls 改变时,更新 Redux 中的 allViewers
+  useEffect(() => {
+    if (imageUrls.length > 0 && JSON.stringify(imageUrls) !== JSON.stringify(allViewerUrls)) {
+      dispatch(setAllViewers(imageUrls));
+    }
+  }, [imageUrls, allViewerUrls, dispatch]);
+
+  // 将 imageUrl 转换为 viewportId
+  const getViewportIdByUrl = (url: string): string | null => {
+    return `viewport-${url}`;
+  };
+
   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(start + index, event)}
-        style={{
-          border: selectedViewers.includes(start + index)
-            ? '2px solid blue'
-            : '1px solid gray',
-          cursor: 'pointer',
-        }}
+        onClick={(event) => handleSelectViewer(url, event)}
       >
-        {/* <input
-          type="checkbox"
-          checked={selectedViewers.includes(index)}
-          onChange={(event) => handleCheckboxChange(index, event)}
-          style={{ marginRight: '8px' }}
-        /> */}
         <StackViewer
           key={start + index}
           imageIndex={0}
           imageUrls={[url]}
-          viewportId={`viewport-${start + index}`}
+          viewportId={getViewportIdByUrl(url) as string}
           renderingEngineId={renderingEngineId}
+          selected={selectedViewerUrls.includes(url)}
         />
       </div>
     ));
   };
 
-  // useEffect(() => {
-
-  // }, [imageUrls]);
-
   useEffect(() => {
     renderGrid();
-  }, [viewersState, selectedBodyPosition, gridLayout]);
+  }, [selectedBodyPosition, gridLayout]);
 
   useEffect(() => {
     if (action) {
       console.log(
         `[ViewerContainer] 处理功能操作: ${action}, selectedViewers:`,
-        selectedViewers
+        selectedViewerUrls
       );
+      
+      // 将选中的 imageUrl 转换为 viewportId
+      const selectedViewportIds = selectedViewerUrls
+        .map(getViewportIdByUrl)
+        .filter((id): id is string => id !== null);
+
       // Handle the action
       switch (action) {
         case 'Add L Mark':
-          selectedViewers.forEach((index) => {
-            // viewersState[index].props.addLMark();
-            addLMark(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            addLMark(viewportId);
           });
-
           break;
         case 'Add R Mark':
-          selectedViewers.forEach((index) => {
-            addRLabel(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            addRLabel(viewportId);
           });
           console.log('Adding R Mark');
           break;
         case 'Delete Selected Mark': {
-          selectedViewers.forEach((index) => {
-            deleteSelectedMark(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            deleteSelectedMark(viewportId);
           });
           break;
         }
         case 'Horizontal Flip': {
-          selectedViewers.forEach((index) => {
-            HorizontalFlip(`viewport-${index}`);
+          console.log(`开始竖直翻转 in viewer container : ${JSON.stringify(selectedViewportIds)}`);
+          selectedViewportIds.forEach((viewportId) => {
+            HorizontalFlip(viewportId);
           });
           break;
         }
         case 'Vertical Flip': {
-          selectedViewers.forEach((index) => {
-            VerticalFlip(`viewport-${index}`);
+          console.log(`开始水平翻转 in viewer container : ${JSON.stringify(selectedViewportIds)}`);
+          selectedViewportIds.forEach((viewportId) => {
+            VerticalFlip(viewportId);
           });
           break;
         }
         case 'Rotate Counterclockwise 90': {
-          selectedViewers.forEach((index) => {
-            RotateCounterclockwise90(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            RotateCounterclockwise90(viewportId);
           });
           break;
         }
         case 'Rotate Clockwise 90':
-          selectedViewers.forEach((index) => {
-            RotateClockwise90(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            RotateClockwise90(viewportId);
           });
           break;
         case 'Rotate Any Angle':
-          selectedViewers.forEach((index) => {
-            rotateAnyAngle(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            rotateAnyAngle(viewportId);
           });
           break;
         case 'AddMask':
-          selectedViewers.forEach((index) => {
-            addMask(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            addMask(viewportId);
           });
           break;
         case 'Delete Digital Mask':
-          selectedViewers.forEach((index) => {
-            remoteMask(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            remoteMask(viewportId);
           });
           break;
         case 'Adjust Brightness and Contrast':
-          selectedViewers.forEach((index) => {
-            adjustBrightnessAndContrast(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            adjustBrightnessAndContrast(viewportId);
           });
           break;
         case 'Crop Selected Area':
-          // selectedViewers.forEach((index) => {
-          //   cropSelectedArea(`viewport-${index}`);
-          // });
           console.log('Cropping Selected Area');
           break;
         case 'Delete Mask':
-          // selectedViewers.forEach((index) => {
-          //   deleteMask(`viewport-${index}`);
-          // });
           console.log('Deleting Mask');
           break;
         case 'Image Comparison':
-          // selectedViewers.forEach((index) => {
-          //   imageComparison(`viewport-${index}`);
-          // });
           console.log('Comparing Images');
           break;
         case 'Invert Contrast':
-          selectedViewers.forEach((index) => {
-            invertContrast(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            invertContrast(viewportId);
           });
           console.log('Inverting Contrast');
           break;
         case '1x1 Layout':
-          setGridLayout('1x1');
-          // renderGrid('1x1');
+          dispatch(setGridLayoutAction('1x1'));
           console.log(`1x1 Layout`);
           break;
         case '1x2 Layout':
-          setGridLayout('1x2');
+          dispatch(setGridLayoutAction('1x2'));
           break;
         case '2x1 Layout':
-          setGridLayout('2x1');
+          dispatch(setGridLayoutAction('2x1'));
           break;
         case '2x2 Layout':
-          setGridLayout('2x2');
+          dispatch(setGridLayoutAction('2x2'));
           break;
         case 'Magnifier': {
-          selectedViewers.forEach((index) => {
-            activateMagnifier(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            activateMagnifier(viewportId);
           });
           break;
         }
         case 'Fit Size': {
-          selectedViewers.forEach((index) => {
-            fitImageSize(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            fitImageSize(viewportId);
           });
           console.log('Fitting Image Size');
           break;
         }
         case 'Original Size': {
-          selectedViewers.forEach((index) => {
-            setOriginalSize(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            setOriginalSize(viewportId);
           });
           console.log('Setting Image to Original Size');
           break;
         }
         case 'Zoom Image':
-          // selectedViewers.forEach((index) => {
-          //   zoomImage(`viewport-${index}`);
-          // });
           console.log('Zooming Image');
           break;
         case 'Reset Cursor':
-          // selectedViewers.forEach((index) => {
-          //   resetCursor(`viewport-${index}`);
-          // });
           console.log('Resetting Cursor');
           break;
         case 'Pan':
-          // selectedViewers.forEach((index) => {
-          //   panImage(`viewport-${index}`);
-          // });
           console.log('Panning Image');
           break;
         case 'Invert Image':
-          selectedViewers.forEach((index) => {
-            InvertImage(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            InvertImage(viewportId);
           });
           break;
         case 'Reset Image':
-          selectedViewers.forEach((index) => {
-            ResetImage(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            ResetImage(viewportId);
           });
           break;
         case 'Snapshot':
-          // selectedViewers.forEach((index) => {
-          //   takeSnapshot(`viewport-${index}`);
-          // });
           console.log('Taking Snapshot');
           break;
         case 'Advanced Processing':
-          // selectedViewers.forEach((index) => {
-          //   advancedProcessing(`viewport-${index}`);
-          // });
           console.log('Performing Advanced Processing');
           break;
         case 'Musician':
-          // selectedViewers.forEach((index) => {
-          //   activateMusician(`viewport-${index}`);
-          // });
           console.log('Activating Musician');
           break;
         case 'Image Measurement':
-          // selectedViewers.forEach((index) => {
-          //   imageMeasurement(`viewport-${index}`);
-          // });
           console.log('Measuring Image');
           break;
         case 'More':
-          // selectedViewers.forEach((index) => {
-          //   moreOptions(`viewport-${index}`);
-          // });
           console.log('Showing More Options');
           break;
         case 'Apply Colormap':
-          selectedViewers.forEach((index) => {
-            ApplyColormap(`viewport-${index}`);
+          selectedViewportIds.forEach((viewportId) => {
+            ApplyColormap(viewportId);
           });
           break;
         // ==================== 线段测量相关操作 ====================
         case '线段测量':
-          if (selectedViewers.length > 0) {
-            selectedViewers.forEach((index) => {
-              activateLengthMeasurement(`viewport-${index}`);
+          if (selectedViewportIds.length > 0) {
+            selectedViewportIds.forEach((viewportId) => {
+              activateLengthMeasurement(viewportId);
             });
           } else {
             // 如果没有选中的 viewport,为所有可见的 viewport 激活
@@ -339,9 +316,9 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
           console.log('Activating Length Measurement');
           break;
         case '清除测量':
-          if (selectedViewers.length > 0) {
-            selectedViewers.forEach((index) => {
-              clearLengthMeasurements(`viewport-${index}`);
+          if (selectedViewportIds.length > 0) {
+            selectedViewportIds.forEach((viewportId) => {
+              clearLengthMeasurements(viewportId);
             });
           } else {
             // 如果没有选中的 viewport,清除所有可见 viewport 的测量
@@ -353,9 +330,9 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
           console.log('Clearing Length Measurements');
           break;
         case '停用线段测量':
-          if (selectedViewers.length > 0) {
-            selectedViewers.forEach((index) => {
-              deactivateLengthMeasurement(`viewport-${index}`);
+          if (selectedViewportIds.length > 0) {
+            selectedViewportIds.forEach((viewportId) => {
+              deactivateLengthMeasurement(viewportId);
             });
           } else {
             // 如果没有选中的 viewport,停用所有可见 viewport 的测量工具
@@ -371,24 +348,29 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
       }
       dispatch(clearAction()); //清理后可连续同一个action触发响应
     }
-  }, [action, selectedViewers, dispatch]);
+  }, [action, selectedViewerUrls, dispatch]);
 
   // ==================== 测量面板 Action 处理 ====================
   useEffect(() => {
     if (measurementAction) {
       console.log(`[ViewerContainer] 处理测量操作: ${measurementAction}`);
 
+      // 将选中的 imageUrl 转换为 viewportId
+      const selectedViewportIds = selectedViewerUrls
+        .map(getViewportIdByUrl)
+        .filter((id): id is string => id !== null);
+
       // 处理测量相关操作
       switch (measurementAction) {
         case '线段测量':
-          if (selectedViewers.length > 0) {
-            selectedViewers.forEach((index) => {
-              const success = activateLengthMeasurement(`viewport-${index}`);
+          if (selectedViewportIds.length > 0) {
+            selectedViewportIds.forEach((viewportId) => {
+              const success = activateLengthMeasurement(viewportId);
               if (success) {
                 dispatch(
                   setToolActive({
                     toolName: 'LengthTool',
-                    viewportId: `viewport-${index}`,
+                    viewportId: viewportId,
                   })
                 );
               }
@@ -412,9 +394,9 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
           break;
 
         case '清除测量':
-          if (selectedViewers.length > 0) {
-            selectedViewers.forEach((index) => {
-              clearLengthMeasurements(`viewport-${index}`);
+          if (selectedViewportIds.length > 0) {
+            selectedViewportIds.forEach((viewportId) => {
+              clearLengthMeasurements(viewportId);
             });
           } else {
             // 如果没有选中的 viewport,清除所有可见 viewport 的测量
@@ -427,14 +409,14 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
           break;
 
         case '停用线段测量':
-          if (selectedViewers.length > 0) {
-            selectedViewers.forEach((index) => {
-              const success = deactivateLengthMeasurement(`viewport-${index}`);
+          if (selectedViewportIds.length > 0) {
+            selectedViewportIds.forEach((viewportId) => {
+              const success = deactivateLengthMeasurement(viewportId);
               if (success) {
                 dispatch(
                   setToolInactive({
                     toolName: 'LengthTool',
-                    viewportId: `viewport-${index}`,
+                    viewportId: viewportId,
                   })
                 );
               }
@@ -473,7 +455,7 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
       // 清理测量 action
       dispatch(clearMeasurementAction());
     }
-  }, [measurementAction, selectedViewers, gridLayout, dispatch]);
+  }, [measurementAction, selectedViewerUrls, gridLayout, dispatch]);
 
   /**
    * 获取当前可见的 viewport 数量
@@ -492,57 +474,24 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
     }
   };
 
-  const handleSelectViewer = (index: number, event) => {
-    setSelectedViewers((prev) => {
-      const newSelected = prev.includes(index)
-        ? prev.filter((i) => i !== index)
-        : [...prev, index];
-
-      console.log(`handleSelectViewer : ${index}`);
-      console.log(`selectedViewers 旧值:`, prev);
-      console.log(`selectedViewers 新值:`, newSelected);
-
-      // 使用计算后的新值来更新边框,避免闭包陷阱
-      const viewerElement = event.currentTarget;
-      if (viewerElement) {
-        viewerElement.style.border = newSelected.includes(index)
-          ? '2px solid blue'
-          : '1px solid gray';
-      }
-
-      return newSelected;
-    });
+  const handleSelectViewer = (imageUrl: string, event: React.MouseEvent) => {
+    console.log(`handleSelectViewer : ${imageUrl}`);
+    console.log(`selectedViewers 旧值:`, selectedViewerUrls);
+    dispatch(toggleViewerSelection(imageUrl));
   };
 
-  // const handleCheckboxChange = (index: number, event) => {
-  //   setSelectedViewers((prev) =>
-  //     event.target.checked ? [...prev, index] : prev.filter((i) => i !== index)
-  //   );
-
-  //   event.target.checked = !event.target.checked;
-  // };
-
   const renderGrid = () => {
     console.log('Rendering  layout', gridLayout);
     switch (gridLayout) {
       case '1x1': {
-        const index = bodyPositions
-          .filter((bp) => bp.dview.expose_status === 'Exposed')
-          .findIndex(
-            (bp) =>
-              bp.sop_instance_uid === selectedBodyPosition?.sop_instance_uid
-          );
-        console.log(`查找到索引:${index}`);
-        if (index !== -1) {
           return (
             <div
               className="h-full w-full"
               style={{ display: 'grid', gridTemplateColumns: '1fr' }}
             >
-              {renderViewers(index, index + 1)}
+              {renderViewers(0, 1)}
             </div>
           );
-        }
         break;
       }
       case '1x2':

+ 13 - 5
src/pages/view/components/viewers/stack.image.viewer.tsx

@@ -6,6 +6,7 @@ import { annotation, SplineROITool } from '@cornerstonejs/tools';
 import { eventTarget } from '@cornerstonejs/core';
 import { registerGlobalTools } from '@/utils/cornerstoneToolsSetup';
 import { MeasurementToolManager } from '@/utils/measurementToolManager';
+import { boolean } from 'zod';
 
 const {
   MagnifyTool,
@@ -330,9 +331,9 @@ export function RotateCounterclockwise90(currentViewportId: string): void {
   // 计算旋转后的viewUp向量(这才是正确的相对旋转)
   const newViewUp: [number, number, number] = [
     currentViewUp[0] * Math.cos(ninetyDegreesRadians) -
-      currentViewUp[1] * Math.sin(ninetyDegreesRadians),
+    currentViewUp[1] * Math.sin(ninetyDegreesRadians),
     currentViewUp[0] * Math.sin(ninetyDegreesRadians) +
-      currentViewUp[1] * Math.cos(ninetyDegreesRadians),
+    currentViewUp[1] * Math.cos(ninetyDegreesRadians),
     0,
   ];
 
@@ -364,9 +365,9 @@ export function RotateClockwise90(currentViewportId: string): void {
   // 计算旋转后的viewUp向量(这才是正确的相对旋转)
   const newViewUp: [number, number, number] = [
     currentViewUp[0] * Math.cos(ninetyDegreesRadians) -
-      currentViewUp[1] * Math.sin(ninetyDegreesRadians),
+    currentViewUp[1] * Math.sin(ninetyDegreesRadians),
     currentViewUp[0] * Math.sin(ninetyDegreesRadians) +
-      currentViewUp[1] * Math.cos(ninetyDegreesRadians),
+    currentViewUp[1] * Math.cos(ninetyDegreesRadians),
     0,
   ];
 
@@ -552,11 +553,13 @@ const StackViewer = ({
   imageUrls = [],
   viewportId,
   renderingEngineId,
+  selected
 }: {
   imageIndex?: number;
   imageUrls?: string[];
   viewportId: string;
   renderingEngineId: string;
+  selected?: boolean;
 }) => {
   const elementRef = useRef<HTMLDivElement>(null);
   // const action = useSelector((state: RootState) => state.functionArea.action);
@@ -660,7 +663,12 @@ const StackViewer = ({
     <div
       ref={elementRef}
       onContextMenu={(e) => e.preventDefault()}
-      style={{ width: '100%', height: '100%', backgroundColor: '#000' }}
+      style={{
+        width: '100%', height: '100%', backgroundColor: '#000',
+        border: selected
+          ? '2px solid blue'
+          : '1px solid gray',
+      }}
     />
   );
 };

+ 2 - 0
src/states/store.ts

@@ -17,6 +17,7 @@ import bodyPositionListenerMiddleware from './exam/bodyPositionListener';
 import { aprMiddleware } from './exam/aprSlice';
 import functionAreaReducer from './view/functionAreaSlice';
 import measurementPanelReducer from './view/measurementPanelSlice';
+import viewerContainerReducer from './view/viewerContainerSlice';
 import searchReducer from './patient/worklist/slices/searchSlice';
 import businessFlowMiddleware from './businessFlowMiddleware';
 import leavingRegisterMonitor from './leavingRegisterMonitor';
@@ -78,6 +79,7 @@ const store = configureStore({
     apr: aprReducer,
     functionArea: functionAreaReducer,
     measurementPanel: measurementPanelReducer,
+    viewerContainer: viewerContainerReducer,
     workEntities: workEntitiesSlice.reducer,
     workFilters: workFiltersSlice.reducer,
     workPagination: workPaginationSlice.reducer,

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

@@ -0,0 +1,228 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { RootState } from '../store';
+
+/**
+ * 分格布局类型
+ */
+export type GridLayout = '1x1' | '1x2' | '2x1' | '2x2';
+
+/**
+ * ViewerContainer 状态接口
+ */
+interface ViewerContainerState {
+  /** 当前分格模式 */
+  gridLayout: GridLayout;
+  /** 选中的 viewer 列表(存储 imageUrl) */
+  selectedViewers: string[];
+  /** 所有 viewer 的 imageUrl 列表 */
+  allViewers: string[];
+}
+
+/**
+ * 初始状态
+ */
+const initialState: ViewerContainerState = {
+  gridLayout: '1x1',
+  selectedViewers: [],
+  allViewers: [],
+};
+
+/**
+ * ViewerContainer Slice
+ */
+const viewerContainerSlice = createSlice({
+  name: 'viewerContainer',
+  initialState,
+  reducers: {
+    /**
+     * 设置分格模式
+     */
+    setGridLayout: (state, action: PayloadAction<GridLayout>) => {
+      state.gridLayout = action.payload;
+    },
+
+    /**
+     * 设置所有 viewer 的 imageUrl 列表
+     */
+    setAllViewers: (state, action: PayloadAction<string[]>) => {
+      state.allViewers = action.payload;
+      // 当 allViewers 更新时,清理不存在的选中项
+      state.selectedViewers = state.selectedViewers.filter((url) =>
+        action.payload.includes(url)
+      );
+    },
+
+    /**
+     * 设置选中的 viewers
+     */
+    setSelectedViewers: (state, action: PayloadAction<string[]>) => {
+      // 确保所有选中的 URL 都在 allViewers 中
+      state.selectedViewers = action.payload.filter((url) =>
+        state.allViewers.includes(url)
+      );
+    },
+
+    /**
+     * 切换单个 viewer 的选中状态
+     */
+    toggleViewerSelection: (state, action: PayloadAction<string>) => {
+      const imageUrl = action.payload;
+      const index = state.selectedViewers.indexOf(imageUrl);
+
+      if (index !== -1) {
+        // 已选中,尝试取消选中
+        // 但至少保持一个选中项
+        if (state.selectedViewers.length > 1) {
+          state.selectedViewers.splice(index, 1);
+        } else {
+          console.log('至少需要选择一个 viewport,保持当前选中');
+        }
+      } else {
+        // 未选中,添加到选中列表
+        if (state.allViewers.includes(imageUrl)) {
+          state.selectedViewers.push(imageUrl);
+        }
+      }
+    },
+
+    /**
+     * 选中单个 viewer(替换当前选择)
+     */
+    selectSingleViewer: (state, action: PayloadAction<string>) => {
+      const imageUrl = action.payload;
+      if (state.allViewers.includes(imageUrl)) {
+        state.selectedViewers = [imageUrl];
+      }
+    },
+
+    /**
+     * 添加到选中列表
+     */
+    addToSelection: (state, action: PayloadAction<string>) => {
+      const imageUrl = action.payload;
+      if (
+        state.allViewers.includes(imageUrl) &&
+        !state.selectedViewers.includes(imageUrl)
+      ) {
+        state.selectedViewers.push(imageUrl);
+      }
+    },
+
+    /**
+     * 从选中列表中移除
+     */
+    removeFromSelection: (state, action: PayloadAction<string>) => {
+      const imageUrl = action.payload;
+      const index = state.selectedViewers.indexOf(imageUrl);
+      
+      // 至少保持一个选中项
+      if (index !== -1 && state.selectedViewers.length > 1) {
+        state.selectedViewers.splice(index, 1);
+      }
+    },
+
+    /**
+     * 清空选择(会自动选中第一个)
+     */
+    clearSelection: (state) => {
+      // 如果有 allViewers,自动选中第一个
+      if (state.allViewers.length > 0) {
+        state.selectedViewers = [state.allViewers[0]];
+      } else {
+        state.selectedViewers = [];
+      }
+    },
+
+    /**
+     * 选中所有 viewers
+     */
+    selectAllViewers: (state) => {
+      state.selectedViewers = [...state.allViewers];
+    },
+
+    /**
+     * 重置 ViewerContainer 状态
+     */
+    resetViewerContainer: (state) => {
+      state.gridLayout = '1x1';
+      state.selectedViewers = [];
+      state.allViewers = [];
+    },
+  },
+});
+
+// ==================== Actions ====================
+export const {
+  setGridLayout,
+  setAllViewers,
+  setSelectedViewers,
+  toggleViewerSelection,
+  selectSingleViewer,
+  addToSelection,
+  removeFromSelection,
+  clearSelection,
+  selectAllViewers,
+  resetViewerContainer,
+} = viewerContainerSlice.actions;
+
+// ==================== Selectors ====================
+
+/**
+ * 选择当前分格模式
+ */
+export const selectGridLayout = (state: RootState): GridLayout =>
+  state.viewerContainer.gridLayout;
+
+/**
+ * 选择所有 viewers
+ */
+export const selectAllViewerUrls = (state: RootState): string[] =>
+  state.viewerContainer.allViewers;
+
+/**
+ * 选择已选中的 viewers
+ */
+export const selectSelectedViewers = (state: RootState): string[] =>
+  state.viewerContainer.selectedViewers;
+
+/**
+ * 检查某个 viewer 是否被选中
+ */
+export const selectIsViewerSelected = (imageUrl: string) => (state: RootState): boolean =>
+  state.viewerContainer.selectedViewers.includes(imageUrl);
+
+/**
+ * 获取选中的 viewer 数量
+ */
+export const selectSelectedViewerCount = (state: RootState): number =>
+  state.viewerContainer.selectedViewers.length;
+
+/**
+ * 获取可见的 viewport 数量(根据当前布局)
+ */
+export const selectVisibleViewportCount = (state: RootState): number => {
+  const { gridLayout, allViewers } = state.viewerContainer;
+  switch (gridLayout) {
+    case '1x1':
+      return Math.min(1, allViewers.length);
+    case '1x2':
+    case '2x1':
+      return Math.min(2, allViewers.length);
+    case '2x2':
+      return Math.min(4, allViewers.length);
+    default:
+      return 1;
+  }
+};
+
+/**
+ * 获取当前可见的 viewers(根据布局和选择)
+ */
+export const selectVisibleViewers = (state: RootState): string[] => {
+  const { gridLayout, allViewers } = state.viewerContainer;
+  const maxVisible = selectVisibleViewportCount(state);
+  return allViewers.slice(0, maxVisible);
+};
+
+// ==================== Reducer ====================
+export default viewerContainerSlice.reducer;