Jelajahi Sumber

feat: 初步实现显示PACS节点列表功能,仍需进一步调试

实现了从历史清单发送面板自动拉取和显示PACS节点列表的功能:
- 创建PACS节点状态管理slice,包含异步获取、选择管理等功能
- 重构SendPanel组件,支持动态加载节点、全选、错误处理和重试
- 注册pacsNode reducer到Redux store
- 添加完整的实现规划文档

改动文件:
- docs/实现/PACS节点拉取和显示功能.md (新增)
- src/states/output/pacsNode/pacsNodeSlice.ts (新增)
- src/pages/output/SendPanel.tsx (重构)
- src/states/store.ts (注册reducer)

注:当前版本可以正常显示节点列表和选择操作,实际发送功能待后续实现
dengdx 3 hari lalu
induk
melakukan
349765454a

+ 225 - 0
docs/实现/PACS节点拉取和显示功能.md

@@ -0,0 +1,225 @@
+# PACS节点拉取和显示功能实现规划
+
+## 1. 参与者清单(从粗到细)
+
+### 1.1 页面层级
+- **HistoryList.tsx** - 历史清单页面,包含操作面板切换逻辑
+- **SendPanel.tsx** - 发送面板组件(需要改造)
+
+### 1.2 组件层级
+- **ActionPanel.tsx** - 操作按钮面板,包含发送按钮
+  - `handleSend()` 方法 - 触发切换到发送面板
+
+### 1.3 状态管理层级
+- **historyPanelSwitchSlice.ts** - 面板切换状态管理
+  - `switchToSendPanel` action - 切换到发送面板
+  - `switchToOperationPanel` action - 切换回操作面板
+  
+- **pacsNodeSlice.ts** - PACS节点状态管理(需要创建)
+  - `pacsNodes` state - 存储PACS节点列表
+  - `loading` state - 加载状态
+  - `error` state - 错误信息
+  - `selectedNodeIds` state - 选中的节点ID列表
+  - `fetchPacsNodesThunk` - 异步获取节点的thunk
+  - `toggleNodeSelection` action - 切换节点选中状态
+  - `selectAllNodes` action - 全选节点
+  - `deselectAllNodes` action - 取消全选
+
+### 1.4 API层级
+- **pacsNodeActions.ts** - PACS节点相关API
+  - `getPacsNodeList()` - 获取PACS节点列表(已存在)
+  - 相关类型定义:
+    - `PacsNode` - 节点数据结构
+    - `PacsNodeListResponse` - API响应结构
+
+### 1.5 Store配置
+- **store.ts** - Redux store配置(需要添加新的reducer)
+
+## 2. Todo List
+
+### 需要创建的文件
+- [ ] `src/states/output/pacsNodeSlice.ts` - PACS节点状态管理slice
+
+### 需要修改的文件
+- [ ] `src/pages/output/SendPanel.tsx` - 改造为动态加载PACS节点列表
+- [ ] `src/states/store.ts` - 注册新的pacsNode reducer
+
+### 需要实现的功能
+- [ ] 创建Redux slice管理PACS节点状态
+- [ ] 实现异步thunk获取PACS节点
+- [ ] 改造SendPanel组件,在挂载时自动拉取数据
+- [ ] 实现节点的选中/取消选中功能
+- [ ] 显示加载状态和错误处理
+- [ ] 实现发送按钮的实际功能(连接发送API)
+
+## 3. 交互流程(泳道图)
+
+```
+用户             ActionPanel          historyPanelSwitchSlice    SendPanel          pacsNodeSlice           API
+ │                    │                         │                    │                   │                   │
+ │─点击发送按钮─────>│                         │                    │                   │                   │
+ │                    │                         │                    │                   │                   │
+ │                    │─dispatch(switchToSendPanel)─>│               │                   │                   │
+ │                    │                         │                    │                   │                   │
+ │                    │                         │─更新currentPanel─>│                   │                   │
+ │                    │                         │   为'SendPanel'    │                   │                   │
+ │                    │                         │                    │                   │                   │
+ │                    │                         │                    │─组件挂载────────>│                   │
+ │                    │                         │                    │   useEffect       │                   │
+ │                    │                         │                    │                   │                   │
+ │                    │                         │                    │                   │─dispatch(fetchPacsNodesThunk)─>│
+ │                    │                         │                    │                   │                   │
+ │                    │                         │                    │                   │                   │─调用getPacsNodeList()
+ │                    │                         │                    │                   │                   │
+ │                    │                         │                    │                   │<─返回节点列表───│
+ │                    │                         │                    │                   │                   │
+ │                    │                         │                    │<─更新nodes状态───│                   │
+ │                    │                         │                    │   loading=false   │                   │
+ │                    │                         │                    │                   │                   │
+ │<─────显示节点列表────────────────────────────────────────────────│                   │                   │
+ │                    │                         │                    │                   │                   │
+ │─选择节点─────────────────────────────────────────────────────>│                   │                   │
+ │                    │                         │                    │                   │                   │
+ │                    │                         │                    │─dispatch(toggleNodeSelection)─────>│  │
+ │                    │                         │                    │                   │                   │
+ │                    │                         │                    │<─更新selectedNodeIds───────────────│  │
+ │                    │                         │                    │                   │                   │
+ │<─────更新UI(复选框选中状态)────────────────────────────────────│                   │                   │
+ │                    │                         │                    │                   │                   │
+ │─点击发送图像按钮──────────────────────────────────────────────>│                   │                   │
+ │                    │                         │                    │                   │                   │
+ │                    │                         │                    │─调用发送API────────────────────────>│
+ │                    │                         │                    │   (传入selectedNodeIds)              │
+ │                    │                         │                    │                   │                   │
+ │<─────显示发送结果────────────────────────────────────────────────│<─────────────────────────────────────│
+ │                    │                         │                    │                   │                   │
+```
+
+## 4. 数据流
+
+### 4.1 PACS节点获取流程
+```
+用户操作 → dispatch(switchToSendPanel) → 面板切换 → SendPanel挂载 
+→ dispatch(fetchPacsNodesThunk) → 调用getPacsNodeList() API 
+→ 更新Redux state (pacsNodes, loading, error) → 组件重新渲染 → 显示节点列表
+```
+
+### 4.2 节点选择流程
+```
+用户点击复选框 → dispatch(toggleNodeSelection(nodeId)) 
+→ 更新selectedNodeIds数组 → 组件重新渲染 → 更新复选框状态
+```
+
+### 4.3 发送图像流程
+```
+用户点击发送按钮 → 验证是否选中节点 → 调用发送API(selectedNodeIds, imageData) 
+→ 显示发送进度/结果 → 成功后可选返回操作面板
+```
+
+## 5. 数据结构
+
+### 5.1 Redux State Structure
+```typescript
+// pacsNodeSlice state
+interface PacsNodeState {
+  nodes: PacsNode[];           // PACS节点列表
+  loading: boolean;            // 加载状态
+  error: string | null;        // 错误信息
+  selectedNodeIds: number[];   // 选中的节点ID列表
+}
+
+// PacsNode (已存在于pacsNodeActions.ts)
+interface PacsNode {
+  id: number;
+  name: string;
+  type: string;
+  address: string;
+  port: number;
+  aet: string;
+  aec: string;
+  is_enabled: boolean;
+  is_default: boolean;
+  params?: Record<string, any>;
+}
+```
+
+### 5.2 API Response Structure (已存在)
+```typescript
+interface PacsNodeListResponse {
+  code: string;
+  description: string;
+  solution: string;
+  data: {
+    '@type': string;
+    scp: PacsNode[];
+  };
+}
+```
+
+## 6. 执行流程(从起点开始)
+
+### 起点:用户点击"发送"按钮
+
+#### 第一步:触发面板切换
+1. 用户在 HistoryList 页面点击 ActionPanel 中的"发送"按钮
+2. 调用 `handleSend()` 方法
+3. dispatch `switchToSendPanel()` action
+4. historyPanelSwitchSlice 更新 `currentPanel` 为 'SendPanel'
+5. HistoryList 组件根据状态渲染 SendPanel
+
+#### 第二步:SendPanel 初始化
+1. SendPanel 组件挂载
+2. useEffect 触发,dispatch `fetchPacsNodesThunk()`
+3. 设置 loading 为 true
+
+#### 第三步:获取PACS节点数据
+1. thunk 调用 `getPacsNodeList()` API
+2. API 返回节点列表数据
+3. thunk fulfilled,更新 Redux state:
+   - nodes: 返回的PACS节点数组
+   - loading: false
+   - error: null
+
+#### 第四步:渲染节点列表
+1. SendPanel 从 Redux state 读取 nodes 和 loading
+2. 显示加载状态(如果 loading 为 true)
+3. 遍历 nodes 数组,为每个节点渲染一个 Checkbox 项
+4. 根据 selectedNodeIds 设置复选框的选中状态
+
+#### 第五步:用户选择节点
+1. 用户点击某个节点的复选框
+2. dispatch `toggleNodeSelection(nodeId)` action
+3. Redux state 更新 selectedNodeIds
+4. 组件重新渲染,更新复选框状态
+
+#### 第六步:发送图像
+1. 用户点击"发送图像"按钮
+2. 验证是否选中了至少一个节点
+3. 调用发送图像的API(传入 selectedNodeIds 和图像数据)
+4. 显示发送进度或结果
+5. 成功后可选择返回操作面板
+
+## 7. 错误处理和边界情况
+
+### 7.1 API错误处理
+- 网络请求失败时显示错误信息
+- 提供重试按钮
+
+### 7.2 空数据处理
+- 如果没有可用的PACS节点,显示提示信息
+
+### 7.3 选择验证
+- 发送前验证是否至少选中一个节点
+- 未选中时显示警告提示
+
+## 8. 后续优化建议
+
+1. **添加搜索和过滤功能** - 当节点列表很长时方便查找
+2. **记住上次选择** - 使用 localStorage 保存用户的常用节点选择
+3. **批量操作** - 添加全选/取消全选功能
+4. **节点状态指示** - 显示节点是否在线/可用
+5. **发送进度** - 显示发送任务的进度条
+6. **发送历史** - 记录发送历史便于追溯
+
+## 实现日期
+2025-01-10

+ 163 - 31
src/pages/output/SendPanel.tsx

@@ -1,21 +1,78 @@
-import React, { useState } from 'react';
-import { Layout, Button, Checkbox, Typography, List } from 'antd';
-import { ArrowLeftOutlined } from '@ant-design/icons';
-import { useDispatch } from 'react-redux';
+import React, { useEffect } from 'react';
+import { Layout, Button, Checkbox, Typography, List, Spin, Alert, message } from 'antd';
+import { ArrowLeftOutlined, ReloadOutlined } from '@ant-design/icons';
+import { useDispatch, useSelector } from 'react-redux';
 import { switchToOperationPanel } from '@/states/patient/worklist/slices/historyPanelSwitchSlice';
+import { 
+  fetchPacsNodesThunk, 
+  toggleNodeSelection,
+  selectAllNodes,
+  deselectAllNodes,
+} from '@/states/output/pacsNode/pacsNodeSlice';
+import { RootState, AppDispatch } from '@/states/store';
 import Icon from '@/components/Icon';
 
 const { Header, Content, Footer } = Layout;
 const { Title } = Typography;
 
 const SendImagePage = () => {
-  const [checked, setChecked] = useState(false);
-  const dispatch = useDispatch();
+  const dispatch = useDispatch<AppDispatch>();
 
+  // 从Redux获取PACS节点状态
+  const { nodes, loading, error, selectedNodeIds } = useSelector(
+    (state: RootState) => state.pacsNode
+  );
+
+  // 组件挂载时获取PACS节点列表
+  useEffect(() => {
+    dispatch(fetchPacsNodesThunk());
+  }, [dispatch]);
+
+  // 返回操作面板
   const handleReturn = () => {
     dispatch(switchToOperationPanel());
   };
 
+  // 切换节点选中状态
+  const handleNodeToggle = (nodeId: number) => {
+    dispatch(toggleNodeSelection(nodeId));
+  };
+
+  // 全选/取消全选
+  const handleSelectAll = (checked: boolean) => {
+    if (checked) {
+      dispatch(selectAllNodes());
+    } else {
+      dispatch(deselectAllNodes());
+    }
+  };
+
+  // 重新加载节点列表
+  const handleReload = () => {
+    dispatch(fetchPacsNodesThunk());
+  };
+
+  // 发送图像
+  const handleSend = () => {
+    if (selectedNodeIds.length === 0) {
+      message.warning('请至少选择一个PACS节点');
+      return;
+    }
+    
+    // TODO: 实现实际的发送图像功能
+    message.info(`准备发送到 ${selectedNodeIds.length} 个节点`);
+    console.log('Selected node IDs:', selectedNodeIds);
+  };
+
+  // 是否全选
+  const isAllSelected = nodes.length > 0 && 
+    nodes.filter(node => node.is_enabled).every(node => 
+      selectedNodeIds.includes(node.id)
+    );
+  
+  // 是否部分选中
+  const isIndeterminate = selectedNodeIds.length > 0 && !isAllSelected;
+
   return (
     <Layout className="h-full">
       {/* 顶部导航栏 */}
@@ -24,40 +81,114 @@ const SendImagePage = () => {
           display: 'flex',
           alignItems: 'center',
           padding: '0 16px',
+          justifyContent: 'space-between',
         }}
       >
+        <div style={{ display: 'flex', alignItems: 'center' }}>
+          <Button
+            type="text"
+            icon={<ArrowLeftOutlined />}
+            onClick={handleReturn}
+          />
+          <Title level={5} style={{ margin: 0, lineHeight: '48px' }}>
+            发送图像
+          </Title>
+        </div>
         <Button
           type="text"
-          icon={<ArrowLeftOutlined />}
-          onClick={handleReturn}
+          icon={<ReloadOutlined />}
+          onClick={handleReload}
+          loading={loading}
         />
-        <Title level={5} style={{ margin: 0, lineHeight: '48px' }}>
-          发送图像
-        </Title>
       </Header>
 
       {/* 主体内容 */}
       <Content
         style={{ padding: '16px', maxHeight: '100%', overflowY: 'auto' }}
       >
-        <List
-          dataSource={[
-            { label: 'DVTKSTR SCP' },
-            ...Array.from({ length: 20 }, (_, i) => ({
-              label: `Checkbox ${i + 1}`,
-            })),
-          ]}
-          renderItem={(item) => (
-            <List.Item>
-              <Checkbox
-                checked={checked}
-                onChange={(e) => setChecked(e.target.checked)}
-              >
-                {item.label}
-              </Checkbox>
-            </List.Item>
-          )}
-        />
+        {/* 错误提示 */}
+        {error && (
+          <Alert
+            message="加载失败"
+            description={error}
+            type="error"
+            showIcon
+            closable
+            style={{ marginBottom: 16 }}
+            action={
+              <Button size="small" onClick={handleReload}>
+                重试
+              </Button>
+            }
+          />
+        )}
+
+        {/* 加载状态 */}
+        {loading && (
+          <div style={{ textAlign: 'center', padding: '40px 0' }}>
+            <Spin size="large" tip="正在加载PACS节点..." />
+          </div>
+        )}
+
+        {/* 节点列表 */}
+        {!loading && !error && (
+          <>
+            {nodes.length === 0 ? (
+              <Alert
+                message="暂无PACS节点"
+                description="请先在系统管理中配置PACS节点"
+                type="info"
+                showIcon
+              />
+            ) : (
+              <>
+                {/* 全选复选框 */}
+                <div style={{ marginBottom: 16, borderBottom: '1px solid #f0f0f0', paddingBottom: 8 }}>
+                  <Checkbox
+                    indeterminate={isIndeterminate}
+                    checked={isAllSelected}
+                    onChange={(e) => handleSelectAll(e.target.checked)}
+                  >
+                    全选 ({selectedNodeIds.length}/{nodes.filter(n => n.is_enabled).length})
+                  </Checkbox>
+                </div>
+
+                {/* PACS节点列表 */}
+                <List
+                  dataSource={nodes}
+                  renderItem={(node) => (
+                    <List.Item>
+                      <Checkbox
+                        checked={selectedNodeIds.includes(node.id)}
+                        onChange={() => handleNodeToggle(node.id)}
+                        disabled={!node.is_enabled}
+                      >
+                        <div>
+                          <div style={{ fontWeight: 500 }}>
+                            {node.name}
+                            {node.is_default && (
+                              <span style={{ marginLeft: 8, color: '#1890ff', fontSize: '12px' }}>
+                                [默认]
+                              </span>
+                            )}
+                            {!node.is_enabled && (
+                              <span style={{ marginLeft: 8, color: '#999', fontSize: '12px' }}>
+                                [已禁用]
+                              </span>
+                            )}
+                          </div>
+                          <div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
+                            {node.address}:{node.port} | AET: {node.aet}
+                          </div>
+                        </div>
+                      </Checkbox>
+                    </List.Item>
+                  )}
+                />
+              </>
+            )}
+          </>
+        )}
       </Content>
 
       {/* 底部按钮 */}
@@ -81,9 +212,10 @@ const SendImagePage = () => {
             />
           }
           style={{ width: '100%', maxWidth: '400px' }}
-          onClick={() => alert('发送图像')}
+          onClick={handleSend}
+          disabled={selectedNodeIds.length === 0 || loading}
         >
-          发送图像
+          发送图像 ({selectedNodeIds.length})
         </Button>
       </Footer>
     </Layout>

+ 139 - 0
src/states/output/pacsNode/pacsNodeSlice.ts

@@ -0,0 +1,139 @@
+import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
+import { getPacsNodeList, PacsNode } from '@/API/output/pacsNodeActions';
+
+/**
+ * PACS节点状态接口
+ */
+export interface PacsNodeState {
+  /** PACS节点列表 */
+  nodes: PacsNode[];
+  /** 加载状态 */
+  loading: boolean;
+  /** 错误信息 */
+  error: string | null;
+  /** 选中的节点ID列表 */
+  selectedNodeIds: number[];
+}
+
+/**
+ * 初始状态
+ */
+const initialState: PacsNodeState = {
+  nodes: [],
+  loading: false,
+  error: null,
+  selectedNodeIds: [],
+};
+
+/**
+ * 异步获取PACS节点列表的thunk
+ */
+export const fetchPacsNodesThunk = createAsyncThunk(
+  'pacsNode/fetchPacsNodes',
+  async (_, { rejectWithValue }) => {
+    try {
+      const response = await getPacsNodeList();
+      return response.data.scp;
+    } catch (error) {
+      const errorMessage =
+        error instanceof Error ? error.message : '获取PACS节点列表失败';
+      return rejectWithValue(errorMessage);
+    }
+  }
+);
+
+/**
+ * PACS节点slice
+ */
+const pacsNodeSlice = createSlice({
+  name: 'pacsNode',
+  initialState,
+  reducers: {
+    /**
+     * 切换节点选中状态
+     */
+    toggleNodeSelection: (state, action: PayloadAction<number>) => {
+      const nodeId = action.payload;
+      const index = state.selectedNodeIds.indexOf(nodeId);
+      
+      if (index > -1) {
+        // 如果已选中,则取消选中
+        state.selectedNodeIds.splice(index, 1);
+      } else {
+        // 如果未选中,则添加到选中列表
+        state.selectedNodeIds.push(nodeId);
+      }
+    },
+
+    /**
+     * 全选节点
+     */
+    selectAllNodes: (state) => {
+      state.selectedNodeIds = state.nodes
+        .filter(node => node.is_enabled) // 只选择启用的节点
+        .map(node => node.id);
+    },
+
+    /**
+     * 取消全选
+     */
+    deselectAllNodes: (state) => {
+      state.selectedNodeIds = [];
+    },
+
+    /**
+     * 设置选中的节点ID列表
+     */
+    setSelectedNodeIds: (state, action: PayloadAction<number[]>) => {
+      state.selectedNodeIds = action.payload;
+    },
+
+    /**
+     * 清空错误信息
+     */
+    clearError: (state) => {
+      state.error = null;
+    },
+
+    /**
+     * 重置状态
+     */
+    resetState: () => initialState,
+  },
+  extraReducers: (builder) => {
+    builder
+      // 开始获取PACS节点列表
+      .addCase(fetchPacsNodesThunk.pending, (state) => {
+        state.loading = true;
+        state.error = null;
+      })
+      // 成功获取PACS节点列表
+      .addCase(fetchPacsNodesThunk.fulfilled, (state, action) => {
+        state.loading = false;
+        state.nodes = action.payload;
+        state.error = null;
+        // 自动选择默认节点
+        const defaultNode = action.payload.find(node => node.is_default && node.is_enabled);
+        if (defaultNode) {
+          state.selectedNodeIds = [defaultNode.id];
+        }
+      })
+      // 获取PACS节点列表失败
+      .addCase(fetchPacsNodesThunk.rejected, (state, action) => {
+        state.loading = false;
+        state.error = action.payload as string;
+        state.nodes = [];
+      });
+  },
+});
+
+export const {
+  toggleNodeSelection,
+  selectAllNodes,
+  deselectAllNodes,
+  setSelectedNodeIds,
+  clearError,
+  resetState,
+} = pacsNodeSlice.actions;
+
+export default pacsNodeSlice.reducer;

+ 2 - 0
src/states/store.ts

@@ -69,6 +69,7 @@ import i18nReducer from './i18nSlice';
 import featureNotAvailableReducer from './featureNotAvailableSlice';
 import themeReducer from './themeSlice';
 import cameraReducer from './exam/cameraSlice';
+import pacsNodeReducer from './output/pacsNode/pacsNodeSlice';
 
 const store = configureStore({
   reducer: {
@@ -130,6 +131,7 @@ const store = configureStore({
     featureNotAvailable: featureNotAvailableReducer,
     theme: themeReducer,
     camera: cameraReducer,
+    pacsNode: pacsNodeReducer,
   },
   middleware: (getDefaultMiddleware) =>
     getDefaultMiddleware().concat(