فهرست منبع

feat (1.51.2 -> 1.52.0): 实现图像加载进度指示器功能

- 在 StackViewer 中添加图像加载进度条显示和状态管理
- 在 OperationPanel 中添加全局加载遮罩层
- 新增 imageLoadingSlice 状态管理模块
- 在 Redux store 中注册 imageLoading reducer

改动文件:
- src/pages/view/components/OperationPanel.tsx
- src/pages/view/components/viewers/stack.image.viewer.tsx
- src/states/store.ts
- src/states/view/imageLoadingSlice.ts
dengdx 1 هفته پیش
والد
کامیت
30295820f0

+ 17 - 0
CHANGELOG.md

@@ -2,6 +2,23 @@
 
 本项目的所有重要变更都将记录在此文件中。
 
+## [1.52.0] - 2026-01-06 13:14
+
+### 新增 (Added)
+
+- **实现图像加载进度指示器功能** - 为图像加载过程添加进度条和遮罩层,提升用户体验
+  - 在 StackViewer 中集成图像加载进度条,支持准备和加载阶段的实时进度显示
+  - 在 OperationPanel 中添加全局加载遮罩层,防止用户在加载期间进行其他操作
+  - 新增 imageLoadingSlice 状态管理模块,实现多视口加载状态的集中管理
+  - 在 Redux store 中注册 imageLoading reducer,支持异步加载状态的统一处理
+
+**改动文件:**
+
+- src/pages/view/components/OperationPanel.tsx
+- src/pages/view/components/viewers/stack.image.viewer.tsx
+- src/states/store.ts
+- src/states/view/imageLoadingSlice.ts
+
 ## [1.51.2] - 2026-01-06 11:25
 
 ## [1.51.0] - 2026-01-06 11:25

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "zsis",
-  "version": "1.51.2",
+  "version": "1.52.0",
   "private": true,
   "description": "医学成像系统",
   "main": "main.js",

+ 31 - 2
src/pages/view/components/OperationPanel.tsx

@@ -1,5 +1,6 @@
-import { Divider, Flex } from 'antd';
+import { Divider, Flex, Spin } from 'antd';
 import { useSelector } from 'react-redux';
+import { selectIsAnyViewportLoading } from '@/states/view/imageLoadingSlice';
 import FunctionArea from './FunctionArea';
 import TransferArea from './TransferArea';
 import SendPanelForView from '../../output/SendPanelForView';
@@ -16,6 +17,9 @@ const OperationPanel = () => {
   const currentPanel = useSelector(
     (state: RootState) => state.panelSwitchForView.currentPanel
   );
+  
+  // 检查是否有任何视口正在加载
+  const isAnyViewportLoading = useSelector(selectIsAnyViewportLoading);
 
   const renderPanel = () => {
     switch (currentPanel) {
@@ -72,7 +76,32 @@ const OperationPanel = () => {
     }
   };
 
-  return <div style={{ height: '100%' }}>{renderPanel()}</div>;
+  return (
+    <div style={{ height: '100%', position: 'relative' }}>
+      {renderPanel()}
+      
+      {/* 加载遮罩层 */}
+      {isAnyViewportLoading && (
+        <div
+          style={{
+            position: 'absolute',
+            top: 0,
+            left: 0,
+            right: 0,
+            bottom: 0,
+            backgroundColor: 'rgba(0, 0, 0, 0.3)',
+            zIndex: 1000,
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            cursor: 'not-allowed',
+          }}
+        >
+          <Spin size="large" tip="图像加载中,请稍候..." />
+        </div>
+      )}
+    </div>
+  );
 };
 
 export default OperationPanel;

+ 83 - 3
src/pages/view/components/viewers/stack.image.viewer.tsx

@@ -31,6 +31,12 @@ import { EVENTS } from '@cornerstonejs/core';
 import { useSelector, useDispatch } from 'react-redux';
 import { selectOverlayEnabled } from '@/states/view/dicomOverlaySlice';
 import { initPlayback, setFrameDetectionResult } from '@/states/view/playbackSlice';
+import { 
+  startLoading, 
+  updateLoadingProgress, 
+  completeLoading,
+  selectViewportLoadingInfo 
+} from '@/states/view/imageLoadingSlice';
 import PolygonLengthMeasurementTool from '@/components/measures/PolygonLengthMeasurementTool';
 import PolylineLengthMeasurementTool from '@/components/measures/PolylineLengthMeasurementTool';
 import { PlaybackController } from '@/pages/view/components/playback/PlaybackController';
@@ -54,7 +60,7 @@ const {
 
 // 导入 cursors 用于光标管理
 import { cursors } from '@cornerstonejs/tools';
-import { message } from 'antd';
+import { message, Progress, Spin } from 'antd';
 const { MouseBindings } = csToolsEnums;
 // 全局工具状态变化监听器注册标志
 let cursorResetListenerRegistered = false;
@@ -1729,6 +1735,8 @@ const StackViewer = ({
   const [renderError, setRenderError] = React.useState<Error | null>(null);
   // 获取overlay启用状态
   const overlayEnabled = useSelector(selectOverlayEnabled);
+  // 从 Redux 获取当前视口的加载状态
+  const loadingInfo = useSelector(selectViewportLoadingInfo(viewportId));
   // 获取 dispatch
   const dispatch = useDispatch();
 
@@ -1871,6 +1879,13 @@ const StackViewer = ({
         // 3. 预检查图像可访问性(可选)
         // await validateImageUrls(imageUrls);
 
+        // 🎯 立即显示进度条 - 准备阶段
+        dispatch(startLoading({
+          viewportId,
+          stage: 'preparing',
+          message: '正在分析图像...'
+        }));
+
         // 4. 多帧DICOM预处理:检测并扩展imageIds
         let finalImageUrls = imageUrls;
         let detectedFrameCount = 1;
@@ -1910,8 +1925,41 @@ const StackViewer = ({
           }
         }
 
-        // 5. 设置图像栈,使用扩展后的imageUrls
-        await safeSetStack(viewport, finalImageUrls, imageIndex);
+        // 5. 🎯 切换到加载阶段
+        dispatch(updateLoadingProgress({
+          viewportId,
+          stage: 'loading',
+          message: '加载中...',
+          totalCount: finalImageUrls.length
+        }));
+
+        // 添加图像加载事件监听器来更新进度
+        let loadedCount = 0;
+        const imageLoadedHandler = (evt: any) => {
+          loadedCount++;
+          const newProgress = Math.round((loadedCount / finalImageUrls.length) * 100);
+          console.log(`[StackViewer] 图像加载进度: ${loadedCount}/${finalImageUrls.length} (${newProgress}%)`);
+          
+          dispatch(updateLoadingProgress({
+            viewportId,
+            loadedCount,
+            progress: newProgress
+          }));
+        };
+
+        // 注册事件监听器
+        eventTarget.addEventListener(EVENTS.IMAGE_LOADED, imageLoadedHandler);
+
+        try {
+          // 设置图像栈,使用扩展后的imageUrls
+          await safeSetStack(viewport, finalImageUrls, imageIndex);
+
+          // 加载完成 - 隐藏进度条
+          dispatch(completeLoading(viewportId));
+        } finally {
+          // 清理事件监听器
+          eventTarget.removeEventListener(EVENTS.IMAGE_LOADED, imageLoadedHandler);
+        }
 
         // 5. 初始化播放状态(用于多帧播放控制)
         // 使用 DicomMetadataAnalyzer 进行深度分析,从 DICOM 元数据读取帧数
@@ -2020,6 +2068,8 @@ const StackViewer = ({
         console.log(`重新加载图像----结束`);
 
       } catch (error) {
+        // 加载失败时,隐藏进度条
+        dispatch(completeLoading(viewportId));
 
         // 不直接抛出错误,而是保存到状态中
         // 在下次渲染时抛出,这样 Error Boundary 才能捕获
@@ -2102,6 +2152,36 @@ const StackViewer = ({
         position: 'relative', // 让浮动控制条能够正确定位
       }}
     >
+      {/* 图像加载进度条 */}
+      {loadingInfo?.isLoading && (
+        <div
+          style={{
+            position: 'absolute',
+            top: '50%',
+            left: '50%',
+            transform: 'translate(-50%, -50%)',
+            zIndex: 1000,
+            textAlign: 'center',
+            backgroundColor: 'rgba(0, 0, 0, 0.7)',
+            padding: '20px',
+            borderRadius: '8px',
+          }}
+        >
+          <Progress
+            type="circle"
+            percent={undefined}
+            strokeColor="#1890ff"
+            trailColor="#333"
+            size={80}
+            strokeWidth={6}
+            format={() => ''}//暂时不显示百分比数字,目前不容易得到下载进度,后续再改进
+          />
+          <div style={{ marginTop: 8, color: '#fff', fontSize: 14 }}>
+            {loadingInfo.stage === 'preparing' ? '正在分析图像...' : '加载中...'}
+          </div>
+        </div>
+      )}
+
       {/* 播放控制器(逻辑组件,不渲染UI) */}
       <PlaybackController
         viewportId={viewportId}

+ 2 - 0
src/states/store.ts

@@ -98,6 +98,7 @@ import invertContrastReducer from './view/invertContrastSlice';
 import serverConfigReducer from '../features/serverConfig/state/serverConfigSlice';
 // import annotationReducer from '../features/imageAnnotation/state/annotationSlice';
 import versionUpdateReducer from './versionUpdateSlice';
+import imageLoadingReducer from './view/imageLoadingSlice';
 
 const store = configureStore({
   reducer: {
@@ -185,6 +186,7 @@ const store = configureStore({
     versionUpdate: versionUpdateReducer,
     department: departmentReducer,
     options: optionsReducer,
+    imageLoading: imageLoadingReducer,
   },
   middleware: (getDefaultMiddleware) =>
     getDefaultMiddleware().concat(

+ 158 - 0
src/states/view/imageLoadingSlice.ts

@@ -0,0 +1,158 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { RootState } from '../store';
+
+/**
+ * 单个视口的加载信息
+ */
+export interface LoadingInfo {
+  isLoading: boolean;
+  stage: 'preparing' | 'loading' | 'complete';
+  message: string;
+  loadedCount: number;
+  totalCount: number;
+  progress: number;
+}
+
+/**
+ * 图像加载状态
+ */
+interface ImageLoadingState {
+  loadingViewports: Record<string, LoadingInfo>;
+}
+
+const initialState: ImageLoadingState = {
+  loadingViewports: {},
+};
+
+const imageLoadingSlice = createSlice({
+  name: 'imageLoading',
+  initialState,
+  reducers: {
+    /**
+     * 开始加载图像
+     */
+    startLoading: (
+      state,
+      action: PayloadAction<{
+        viewportId: string;
+        stage: 'preparing' | 'loading';
+        message: string;
+      }>
+    ) => {
+      const { viewportId, stage, message } = action.payload;
+      state.loadingViewports[viewportId] = {
+        isLoading: true,
+        stage,
+        message,
+        loadedCount: 0,
+        totalCount: 0,
+        progress: 0,
+      };
+    },
+
+    /**
+     * 更新加载进度
+     */
+    updateLoadingProgress: (
+      state,
+      action: PayloadAction<{
+        viewportId: string;
+        stage?: 'preparing' | 'loading';
+        message?: string;
+        loadedCount?: number;
+        totalCount?: number;
+        progress?: number;
+      }>
+    ) => {
+      const { viewportId, ...updates } = action.payload;
+      const currentInfo = state.loadingViewports[viewportId];
+      
+      if (currentInfo) {
+        state.loadingViewports[viewportId] = {
+          ...currentInfo,
+          ...updates,
+        };
+      }
+    },
+
+    /**
+     * 完成加载
+     */
+    completeLoading: (state, action: PayloadAction<string>) => {
+      const viewportId = action.payload;
+      const currentInfo = state.loadingViewports[viewportId];
+      
+      if (currentInfo) {
+        state.loadingViewports[viewportId] = {
+          ...currentInfo,
+          isLoading: false,
+          stage: 'complete',
+          progress: 100,
+        };
+        
+        // 可选:清理完成的加载信息(延迟清理以便显示完成状态)
+        // delete state.loadingViewports[viewportId];
+      }
+    },
+
+    /**
+     * 清除视口加载信息
+     */
+    clearViewportLoading: (state, action: PayloadAction<string>) => {
+      const viewportId = action.payload;
+      delete state.loadingViewports[viewportId];
+    },
+
+    /**
+     * 重置所有加载状态
+     */
+    resetAllLoading: (state) => {
+      state.loadingViewports = {};
+    },
+  },
+});
+
+// Actions
+export const {
+  startLoading,
+  updateLoadingProgress,
+  completeLoading,
+  clearViewportLoading,
+  resetAllLoading,
+} = imageLoadingSlice.actions;
+
+// Selectors
+
+/**
+ * 获取指定视口的加载信息
+ */
+export const selectViewportLoadingInfo = (viewportId: string) => (state: RootState): LoadingInfo | undefined => {
+  return state.imageLoading?.loadingViewports[viewportId];
+};
+
+/**
+ * 检查是否有任何视口正在加载
+ */
+export const selectIsAnyViewportLoading = (state: RootState): boolean => {
+  const loadingViewports = state.imageLoading?.loadingViewports || {};
+  return Object.values(loadingViewports).some((info) => info.isLoading);
+};
+
+/**
+ * 获取所有正在加载的视口ID列表
+ */
+export const selectLoadingViewportIds = (state: RootState): string[] => {
+  const loadingViewports = state.imageLoading?.loadingViewports || {};
+  return Object.entries(loadingViewports)
+    .filter(([_, info]) => info.isLoading)
+    .map(([viewportId, _]) => viewportId);
+};
+
+/**
+ * 获取正在加载的视口数量
+ */
+export const selectLoadingViewportCount = (state: RootState): number => {
+  return selectLoadingViewportIds(state).length;
+};
+
+export default imageLoadingSlice.reducer;