Browse Source

feat: 实现View模式下的发送图像功能

- 在 SendPanelForView.tsx 中完全重写发送面板,实现完整的发送图像功能
  - 集成 viewerContainer 和 pacsNode Redux 状态管理
  - 实现图像全选/取消选中功能,显示已选中数量
  - 自动加载并显示 PACS 节点列表,支持节点选择
  - 实现 SOP Instance UID 提取函数(从 imageUrl 提取)
  - 实现发送逻辑:验证、确认对话框、双层循环发送、进度跟踪
  - 添加实时发送进度显示(总数、完成、失败统计)
  - 完善错误处理和用户提示
- 在 viewerContainerSlice.ts 中添加 deselectAllViewers action
  - 支持取消选中所有 viewers 的功能
  - 导出新的 action 供组件使用
- 创建详细的实现文档 docs/实现/发送图像功能-ViewMode.md
  - 包含功能概述、参与者分析、交互流程(泳道图)
  - 数据流说明、数据结构详解、用户旅程
  - 核心代码实现、UI设计、测试要点

改动文件:
- src/pages/output/SendPanelForView.tsx
- src/states/view/viewerContainerSlice.ts
- docs/实现/发送图像功能-ViewMode.md(新增)
sw 2 days ago
parent
commit
f8129f76ec

+ 781 - 0
docs/实现/发送图像功能-ViewMode.md

@@ -0,0 +1,781 @@
+# 发送图像功能 - View Mode 实现文档
+
+## 1. 功能概述
+
+### 1.1 功能描述
+在图像查看页面(View Mode)中,用户可以选择当前查看的图像并将其发送到指定的 PACS 节点。本功能提供了一个发送面板,允许用户:
+- 查看当前选中的图像
+- 全选或取消选中所有图像
+- 选择目标 PACS 节点
+- 批量发送图像到多个 PACS 节点
+
+### 1.2 用户场景
+1. 用户在 ViewerContainer 中查看多张图像
+2. 点击"发送"按钮,打开发送面板
+3. 在发送面板中选择要发送的图像(全选/部分选择)
+4. 选择目标 PACS 节点(可多选)
+5. 点击"发送图像"按钮执行发送
+6. 查看发送进度和结果
+
+## 2. 涉及的参与者
+
+### 2.1 组件层级
+
+#### 页面组件
+- **TransferArea.tsx** - 发送按钮入口组件
+- **SendPanelForView.tsx** - 发送图像面板主组件
+
+#### Redux 状态管理
+- **panelSwitchSliceForView.ts** - 面板切换状态管理
+- **viewerContainerSlice.ts** - Viewer 选择状态管理
+- **pacsNodeSlice.ts** - PACS 节点状态管理
+
+#### API 服务
+- **imageActions.ts** - 图像发送 API
+
+#### 工具函数
+- **bodyPosition.ts** - 提供 `getDcmImageUrl` 函数
+
+### 2.2 核心类型/接口
+
+```typescript
+// Viewer Container 状态
+interface ViewerContainerState {
+  gridLayout: GridLayout;
+  selectedViewers: string[];  // 选中的 imageUrls
+  allViewers: string[];       // 所有的 imageUrls
+}
+
+// PACS 节点
+interface PacsNode {
+  id: number;
+  name: string;
+  address: string;
+  port: number;
+  aet: string;
+  is_enabled: boolean;
+  is_default: boolean;
+}
+
+// PACS 节点状态
+interface PacsNodeState {
+  nodes: PacsNode[];
+  loading: boolean;
+  error: string | null;
+  selectedNodeIds: number[];
+}
+
+// 发送图像请求
+interface SendImageRequest {
+  sop_instance_uid: string;
+  pacs_name: string;
+}
+
+// 发送进度状态
+interface SendingProgress {
+  isSending: boolean;
+  total: number;
+  completed: number;
+  failed: number;
+}
+```
+
+### 2.3 关键方法
+
+#### Redux Actions
+```typescript
+// viewerContainerSlice
+- selectAllViewers()          // 选中所有 viewers
+- deselectAllViewers()         // 取消选中所有 viewers (新增)
+- setSelectedViewers(urls[])   // 设置选中的 viewers
+
+// pacsNodeSlice
+- fetchPacsNodesThunk()        // 异步获取 PACS 节点列表
+- toggleNodeSelection(id)      // 切换节点选中状态
+- selectAllNodes()             // 全选节点
+- deselectAllNodes()           // 取消全选节点
+
+// panelSwitchSliceForView
+- switchToSendPanel()          // 切换到发送面板
+- switchToOperationPanel()     // 返回操作面板
+```
+
+#### 组件方法
+```typescript
+// SendPanelForView
+- handleSelectAllViewers()     // 全选 viewers
+- handleDeselectAllViewers()   // 取消选中 viewers
+- handleNodeToggle(nodeId)     // 切换节点选中
+- handleSend()                 // 发送图像
+- performSend(sopUids, names)  // 执行发送逻辑
+- extractSopInstanceUid(url)   // 提取 SOP Instance UID
+```
+
+## 3. 实现计划
+
+### 3.1 需要创建的内容
+
+1. **新增 Redux Action**
+   - 文件:`src/states/view/viewerContainerSlice.ts`
+   - 内容:添加 `deselectAllViewers` reducer
+   ```typescript
+   deselectAllViewers: (state) => {
+     state.selectedViewers = [];
+   }
+   ```
+
+2. **实现文档**
+   - 文件:`docs/实现/发送图像功能-ViewMode.md`(本文档)
+   - 内容:完整的功能实现文档
+
+### 3.2 需要修改的内容
+
+1. **SendPanelForView.tsx** - 主要实现文件
+   - 导入必要的依赖
+   - 实现状态管理
+   - 实现 UI 渲染
+   - 实现业务逻辑
+
+### 3.3 无需删除的内容
+
+所有现有代码保持不变,仅进行功能扩展。
+
+## 4. 交互流程
+
+### 4.1 泳道图
+
+```
+┌─────────┐   ┌──────────────┐   ┌──────────────────┐   ┌─────────────┐   ┌─────┐
+│  用户    │   │ TransferArea │   │ SendPanelForView │   │ Redux Store │   │ API │
+└────┬────┘   └──────┬───────┘   └────────┬─────────┘   └──────┬──────┘   └──┬──┘
+     │               │                     │                     │              │
+     │ 1. 点击发送   │                     │                     │              │
+     ├──────────────>│                     │                     │              │
+     │               │ 2. switchToSendPanel│                     │              │
+     │               ├────────────────────────────────────────>│              │
+     │               │                     │ 3. 面板切换         │              │
+     │               │                     │<─────────────────────┤              │
+     │               │                     │                     │              │
+     │               │                     │ 4. fetchPacsNodes   │              │
+     │               │                     ├────────────────────>│              │
+     │               │                     │                     │ 5. API 请求  │
+     │               │                     │                     ├─────────────>│
+     │               │                     │                     │ 6. 节点列表  │
+     │               │                     │                     │<─────────────┤
+     │               │                     │ 7. 更新节点状态     │              │
+     │               │                     │<─────────────────────┤              │
+     │               │                     │ 8. 渲染界面         │              │
+     │               │                     │                     │              │
+     │ 9. 点击"全选" │                     │                     │              │
+     ├──────────────────────────────────>│                     │              │
+     │               │                     │ 10. selectAllViewers│              │
+     │               │                     ├────────────────────>│              │
+     │               │                     │ 11. 更新选中状态    │              │
+     │               │                     │<─────────────────────┤              │
+     │               │                     │                     │              │
+     │ 12. 选择节点  │                     │                     │              │
+     ├──────────────────────────────────>│                     │              │
+     │               │                     │ 13. toggleNode      │              │
+     │               │                     ├────────────────────>│              │
+     │               │                     │                     │              │
+     │ 14. 发送图像  │                     │                     │              │
+     ├──────────────────────────────────>│                     │              │
+     │               │                     │ 15. 验证            │              │
+     │               │                     │ 16. 确认对话框      │              │
+     │<──────────────────────────────────┤                     │              │
+     │               │                     │                     │              │
+     │ 17. 确认      │                     │                     │              │
+     ├──────────────────────────────────>│                     │              │
+     │               │                     │ 18. performSend     │              │
+     │               │                     │                     │ 19. 发送图像 │
+     │               │                     │                     ├─────────────>│
+     │               │                     │                     │ 20. 返回结果 │
+     │               │                     │                     │<─────────────┤
+     │               │                     │ 21. 更新进度        │              │
+     │               │                     │ 22. 完成提示        │              │
+     │<──────────────────────────────────┤                     │              │
+     │               │                     │                     │              │
+```
+
+### 4.2 状态流转图
+
+```
+[用户点击发送] 
+    ↓
+[面板切换: OperationPanel → SendPanel]
+    ↓
+[加载 PACS 节点列表]
+    ↓
+[显示发送面板]
+    ├── 顶部: [全选按钮] [取消选中按钮]
+    ├── 中间: [PACS 节点列表]
+    └── 底部: [发送图像按钮]
+    ↓
+[用户操作选择]
+    ├── 选择 Viewers (全选/部分)
+    └── 选择 PACS 节点 (多选)
+    ↓
+[点击发送图像]
+    ↓
+[验证选择]
+    ├── selectedViewers.length > 0 ✓
+    └── selectedNodeIds.length > 0 ✓
+    ↓
+[显示确认对话框]
+    ├── 发送数量: N 个 viewers × M 个节点
+    └── 节点列表: PACS1, PACS2, ...
+    ↓
+[用户确认] → [开始发送]
+    ↓
+[发送循环]
+    for each viewer:
+        for each pacsNode:
+            ├── 调用 sendImageToPacs()
+            ├── 更新进度
+            └── 处理错误
+    ↓
+[显示结果]
+    ├── 成功: X 个
+    └── 失败: Y 个
+```
+
+## 5. 数据流详解
+
+### 5.1 初始化数据流
+
+```
+组件挂载 (SendPanelForView)
+    ↓
+useEffect: dispatch(fetchPacsNodesThunk())
+    ↓
+API 请求: GET /auth/scp
+    ↓
+返回数据: { data: { scp: PacsNode[] } }
+    ↓
+Redux 更新: pacsNodeSlice.nodes = response.data.scp
+    ↓
+自动选择默认节点: selectedNodeIds = [defaultNode.id]
+    ↓
+组件重新渲染
+```
+
+### 5.2 选择数据流
+
+#### Viewer 选择
+```
+用户操作: 点击"全选"
+    ↓
+dispatch(selectAllViewers())
+    ↓
+Redux 更新: selectedViewers = [...allViewers]
+    ↓
+组件重新渲染: 所有 viewers 显示为选中状态
+```
+
+#### PACS 节点选择
+```
+用户操作: 点击节点复选框
+    ↓
+handleNodeToggle(nodeId)
+    ↓
+dispatch(toggleNodeSelection(nodeId))
+    ↓
+Redux 更新: 
+    if (nodeId in selectedNodeIds):
+        remove from array
+    else:
+        add to array
+    ↓
+组件重新渲染: 节点选中状态更新
+```
+
+### 5.3 发送数据流
+
+```
+用户点击"发送图像"
+    ↓
+handleSend()
+    ↓
+验证:
+    - selectedViewers.length > 0 ✓
+    - selectedNodeIds.length > 0 ✓
+    ↓
+提取数据:
+    sopInstanceUids = selectedViewers.map(extractSopInstanceUid)
+    pacsNames = nodes.filter(...).map(n => n.name)
+    ↓
+Modal.confirm() - 显示确认对话框
+    ↓
+用户确认 → performSend(sopInstanceUids, pacsNames)
+    ↓
+初始化进度: { isSending: true, total, completed: 0, failed: 0 }
+    ↓
+双层循环发送:
+    for sopUid in sopInstanceUids:
+        for pacsName in pacsNames:
+            try:
+                await sendImageToPacs(sopUid, pacsName)
+                completed++
+                更新进度状态
+            catch error:
+                failed++
+                更新进度状态
+                log error
+    ↓
+显示结果:
+    if failed == 0:
+        message.success("全部成功")
+    else:
+        message.warning("部分失败")
+    ↓
+重置进度状态: { isSending: false, ... }
+```
+
+## 6. 数据结构详解
+
+### 6.1 ViewerContainer 状态示例
+
+```typescript
+{
+  gridLayout: '2x2',
+  selectedViewers: [
+    "dicomweb:http://localhost:8000/pub/dcm/1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668671.dcm",
+    "dicomweb:http://localhost:8000/pub/dcm/1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668672.dcm"
+  ],
+  allViewers: [
+    "dicomweb:http://localhost:8000/pub/dcm/1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668671.dcm",
+    "dicomweb:http://localhost:8000/pub/dcm/1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668672.dcm",
+    "dicomweb:http://localhost:8000/pub/dcm/1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668673.dcm",
+    "dicomweb:http://localhost:8000/pub/dcm/1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668674.dcm"
+  ]
+}
+```
+
+### 6.2 PACS 节点状态示例
+
+```typescript
+{
+  nodes: [
+    {
+      id: 1,
+      name: "PACS_Main",
+      address: "192.168.1.100",
+      port: 11112,
+      aet: "PACS1",
+      is_enabled: true,
+      is_default: true
+    },
+    {
+      id: 2,
+      name: "PACS_Backup",
+      address: "192.168.1.101",
+      port: 11112,
+      aet: "PACS2",
+      is_enabled: true,
+      is_default: false
+    },
+    {
+      id: 3,
+      name: "PACS_Archive",
+      address: "192.168.1.102",
+      port: 11112,
+      aet: "PACS3",
+      is_enabled: false,
+      is_default: false
+    }
+  ],
+  selectedNodeIds: [1, 2],
+  loading: false,
+  error: null
+}
+```
+
+### 6.3 发送进度状态示例
+
+```typescript
+// 发送中
+{
+  isSending: true,
+  total: 6,        // 2 images × 3 PACS nodes
+  completed: 4,    // 已完成 4 个
+  failed: 1        // 失败 1 个
+}
+
+// 发送完成
+{
+  isSending: false,
+  total: 0,
+  completed: 0,
+  failed: 0
+}
+```
+
+### 6.4 imageUrl 与 SOP Instance UID 映射
+
+```typescript
+// imageUrl 格式
+"dicomweb:http://localhost:8000/pub/dcm/1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668671.dcm"
+
+// 提取 SOP Instance UID
+const extractSopInstanceUid = (imageUrl: string): string => {
+  // 匹配 .dcm 前的最后一段
+  const match = imageUrl.match(/\/([^\/]+)\.dcm$/);
+  return match ? match[1] : '';
+};
+
+// 结果
+"1.2.276.0.1000000.5.1.4.701601461.19649.1749545373.668671"
+```
+
+## 7. 执行流程(用户视角)
+
+### 7.1 完整用户旅程
+
+```
+第1步: 查看图像
+    用户在 View 页面查看图像
+    ViewerContainer 显示多张图像
+    部分图像已被选中(selectedViewers)
+
+第2步: 打开发送面板
+    用户点击 TransferArea 的"发送"按钮
+    面板切换到 SendPanelForView
+    自动加载 PACS 节点列表
+
+第3步: 查看当前状态
+    顶部显示:
+        - [全选] 按钮
+        - [取消选中] 按钮
+    中间显示:
+        - PACS 节点列表
+        - 每个节点显示: 名称、地址、AET
+        - 已启用节点可选,已禁用节点不可选
+        - 默认节点自动选中
+    底部显示:
+        - [发送图像 (N)] 按钮
+        - N = 当前选中的节点数量
+
+第4步: 调整图像选择(可选)
+    选项A: 点击"全选"
+        → 所有 viewers 被选中
+    选项B: 点击"取消选中"
+        → 所有 viewers 取消选中
+    选项C: 保持当前选择
+        → 不做任何操作
+
+第5步: 选择 PACS 节点
+    用户勾选/取消勾选节点复选框
+    至少选择一个节点
+    可以选择多个节点
+
+第6步: 点击"发送图像"
+    系统验证:
+        ✓ 至少有1个 viewer 被选中
+        ✓ 至少有1个 PACS 节点被选中
+    
+    验证通过 → 显示确认对话框
+    验证失败 → 显示警告消息
+
+第7步: 确认发送
+    对话框显示:
+        - 即将发送 X 个图像
+        - 发送到 Y 个 PACS 节点
+        - 节点列表: PACS1, PACS2, ...
+        - 总任务数: X × Y
+    
+    用户选择:
+        → [确认发送] → 继续
+        → [取消] → 返回面板
+
+第8步: 发送过程
+    显示进度条:
+        - 总任务数: X × Y
+        - 已完成: N
+        - 已失败: M
+        - 进度百分比
+    
+    后台执行:
+        for 每个图像:
+            for 每个节点:
+                发送图像
+                更新进度
+
+第9步: 查看结果
+    全部成功:
+        → 显示成功消息: "发送完成!成功发送 N 个任务"
+    部分失败:
+        → 显示警告消息: "发送完成!成功 X 个,失败 Y 个"
+    
+    用户可以:
+        → 继续发送其他图像
+        → 点击返回按钮回到操作面板
+```
+
+### 7.2 异常情况处理
+
+```
+场景1: 未选择 viewer
+    用户点击"发送图像"
+    → 显示警告: "请先选择要发送的图像"
+    → 停留在当前面板
+
+场景2: 未选择 PACS 节点
+    用户点击"发送图像"
+    → 显示警告: "请至少选择一个PACS节点"
+    → 停留在当前面板
+
+场景3: 加载 PACS 节点失败
+    进入面板时加载失败
+    → 显示错误提示框
+    → 提供"重试"按钮
+    → 用户可以重新加载
+
+场景4: 发送部分失败
+    发送过程中部分失败
+    → 继续发送其他任务
+    → 统计成功和失败数量
+    → 最后显示汇总结果
+    → 失败详情记录在控制台
+
+场景5: 发送全部失败
+    所有发送任务都失败
+    → 显示警告消息
+    → 提示用户检查网络或节点配置
+```
+
+## 8. 核心代码实现
+
+### 8.1 SOP Instance UID 提取函数
+
+```typescript
+/**
+ * 从 imageUrl 中提取 SOP Instance UID
+ * @param imageUrl - 图像 URL,格式: "dicomweb:http://localhost:8000/pub/dcm/1.2.3.4.5.dcm"
+ * @returns SOP Instance UID
+ */
+const extractSopInstanceUid = (imageUrl: string): string => {
+  // 匹配 .dcm 扩展名前的最后一段路径
+  const match = imageUrl.match(/\/([^\/]+)\.dcm$/);
+  if (!match) {
+    console.error('无法从 imageUrl 提取 SOP Instance UID:', imageUrl);
+    return '';
+  }
+  return match[1];
+};
+```
+
+### 8.2 全选/取消选中实现
+
+```typescript
+// 全选
+const handleSelectAllViewers = () => {
+  dispatch(selectAllViewers());
+};
+
+// 取消选中
+const handleDeselectAllViewers = () => {
+  dispatch(deselectAllViewers());
+};
+```
+
+### 8.3 发送图像核心逻辑
+
+```typescript
+const performSend = async (
+  sopInstanceUids: string[],
+  pacsNames: string[]
+) => {
+  const total = sopInstanceUids.length * pacsNames.length;
+  
+  setSendingProgress({
+    isSending: true,
+    total,
+    completed: 0,
+    failed: 0,
+  });
+  
+  let completed = 0;
+  let failed = 0;
+  
+  try {
+    for (const sopUid of sopInstanceUids) {
+      for (const pacsName of pacsNames) {
+        try {
+          await sendImageToPacs(sopUid, pacsName);
+          completed++;
+          setSendingProgress({
+            isSending: true,
+            total,
+            completed,
+            failed,
+          });
+        } catch (error) {
+          failed++;
+          setSendingProgress({
+            isSending: true,
+            total,
+            completed,
+            failed,
+          });
+          console.error(
+            `发送失败 [SOP: ${sopUid} -> PACS: ${pacsName}]:`,
+            error
+          );
+        }
+      }
+    }
+    
+    // 显示结果
+    if (failed === 0) {
+      message.success(`发送完成!成功发送 ${completed} 个任务`);
+    } else {
+      message.warning(
+        `发送完成!成功 ${completed} 个,失败 ${failed} 个`
+      );
+    }
+  } finally {
+    setSendingProgress({
+      isSending: false,
+      total: 0,
+      completed: 0,
+      failed: 0,
+    });
+  }
+};
+```
+
+### 8.4 发送按钮点击处理
+
+```typescript
+const handleSend = () => {
+  // 验证 viewer 选择
+  if (selectedViewers.length === 0) {
+    message.warning('请先选择要发送的图像');
+    return;
+  }
+  
+  // 验证节点选择
+  if (selectedNodeIds.length === 0) {
+    message.warning('请至少选择一个PACS节点');
+    return;
+  }
+  
+  // 提取 SOP Instance UIDs
+  const sopInstanceUids = selectedViewers
+    .map(extractSopInstanceUid)
+    .filter(uid => uid !== ''); // 过滤无效 UID
+  
+  if (sopInstanceUids.length === 0) {
+    message.error('无法提取图像 UID,请检查图像数据');
+    return;
+  }
+  
+  // 获取节点名称
+  const selectedNodes = nodes.filter(node => 
+    selectedNodeIds.includes(node.id)
+  );
+  const pacsNames = selectedNodes.map(node => node.name);
+  const nodeNames = pacsNames.join(', ');
+  
+  // 显示确认对话框
+  Modal.confirm({
+    title: '确认发送图像',
+    content: (
+      <div>
+        <p>
+          即将发送 <strong>{sopInstanceUids.length}</strong> 个图像到以下 PACS 节点:
+        </p>
+        <p style={{ marginTop: 8, color: '#1890ff' }}>
+          {nodeNames}
+        </p>
+        <p style={{ marginTop: 8 }}>
+          共 <strong>{sopInstanceUids.length * pacsNames.length}</strong> 个发送任务
+        </p>
+      </div>
+    ),
+    okText: '确认发送',
+    cancelText: '取消',
+    okButtonProps: { danger: true },
+    onOk: async () => {
+      await performSend(sopInstanceUids, pacsNames);
+    },
+  });
+};
+```
+
+## 9. UI 设计
+
+### 9.1 布局结构
+
+```
+┌─────────────────────────────────────────┐
+│ Header: [<] 发送图像              [重载] │
+├─────────────────────────────────────────┤
+│ Content:                                │
+│                                         │
+│  ┌─────────────────────────────────┐   │
+│  │ [√ 全选] [□ 取消选中]          │   │
+│  └─────────────────────────────────┘   │
+│                                         │
+│  ┌─────────────────────────────────┐   │
+│  │ □ PACS_Main                     │   │
+│  │   192.168.1.100:11112 | AET:... │   │
+│  ├─────────────────────────────────┤   │
+│  │ □ PACS_Backup (默认节点)       │   │
+│  │   192.168.1.101:11112 | AET:... │   │
+│  ├─────────────────────────────────┤   │
+│  │ □ PACS_Archive [已禁用]        │   │
+│  │   192.168.1.102:11112 | AET:... │   │
+│  └─────────────────────────────────┘   │
+│                                         │
+├─────────────────────────────────────────┤
+│ Footer:                                 │
+│  ┌──────────────────────────────┐      │
+│  │  [图标] 发送图像 (2)        │      │
+│  └──────────────────────────────┘      │
+└─────────────────────────────────────────┘
+```
+
+### 9.2 发送进度显示
+
+```
+┌─────────────────────────────────────────┐
+│ 正在发送: 5 / 12                        │
+│ ████████████░░░░░░░░░░░░ 42%           │
+│ 成功: 4          失败: 1               │
+└─────────────────────────────────────────┘
+```
+
+### 9.3 确认对话框
+
+```
+┌─────────────────────────────────────┐
+│ 确认发送图像                         │
+├─────────────────────────────────────┤
+│                                     │
+│ 即将发送 3 个图像到以下 PACS 节点: │
+│                                     │
+│ PACS_Main, PACS_Backup             │
+│                                     │
+│ 共 6 个发送任务                    │
+│                                     │
+├─────────────────────────────────────┤
+│           [取消]  [确认发送]        │
+└─────────────────────────────────────┘
+```
+
+## 10. 测试要点
+
+### 10.1 功能测试
+
+1. **面板切换**
+   - 点击发送按钮,面板正确切换
+   - 点击返回按钮,返回操作面板
+
+2. **PACS 节点加载**
+   - 面板打开时自动加载节点
+   - 加载成功显示节点列表
+   - 加载失败显示错误提示
+   - 重载按钮功能正常
+
+3. **Viewer 选

+ 428 - 38
src/pages/output/SendPanelForView.tsx

@@ -1,26 +1,248 @@
-import React, { useState } from 'react';
-import { Layout, Button, Checkbox, Typography, List, Space } from 'antd';
+import React, { useState, useEffect } from 'react';
+import { Layout, Button, Checkbox, Typography, List, Space, Spin, Alert, message, Modal, Progress } from 'antd';
 import {
   ArrowLeftOutlined,
   CheckSquareOutlined,
   CloseSquareOutlined,
-  PictureOutlined,
+  ReloadOutlined,
 } from '@ant-design/icons';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
 import { switchToOperationPanel } from '../../states/panelSwitchSliceForView';
+import { 
+  selectAllViewers, 
+  deselectAllViewers,
+  selectSelectedViewers,
+} from '../../states/view/viewerContainerSlice';
+import { 
+  fetchPacsNodesThunk, 
+  toggleNodeSelection,
+  selectAllNodes,
+  deselectAllNodes,
+} from '@/states/output/pacsNode/pacsNodeSlice';
+import { sendImageToPacs } from '@/API/imageActions';
+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();
+/**
+ * 发送进度状态接口
+ */
+interface SendingProgress {
+  isSending: boolean;
+  total: number;
+  completed: number;
+  failed: number;
+}
 
+/**
+ * 从 imageUrl 中提取 SOP Instance UID
+ * @param imageUrl - 图像 URL,格式: "dicomweb:http://localhost:8000/pub/dcm/1.2.3.4.5.dcm"
+ * @returns SOP Instance UID
+ */
+const extractSopInstanceUid = (imageUrl: string): string => {
+  // 匹配 .dcm 扩展名前的最后一段路径
+  const match = imageUrl.match(/\/([^\/]+)\.dcm$/);
+  if (!match) {
+    console.error('无法从 imageUrl 提取 SOP Instance UID:', imageUrl);
+    return '';
+  }
+  return match[1];
+};
+
+const SendPanelForView = () => {
+  const dispatch = useDispatch<AppDispatch>();
+
+  // 从 Redux 获取 viewers 状态
+  const selectedViewers = useSelector(selectSelectedViewers);
+
+  // 从 Redux 获取 PACS 节点状态
+  const { nodes, loading, error, selectedNodeIds } = useSelector(
+    (state: RootState) => state.pacsNode
+  );
+
+  // 发送进度状态
+  const [sendingProgress, setSendingProgress] = useState<SendingProgress>({
+    isSending: false,
+    total: 0,
+    completed: 0,
+    failed: 0,
+  });
+
+  // 组件挂载时获取 PACS 节点列表
+  useEffect(() => {
+    dispatch(fetchPacsNodesThunk());
+  }, [dispatch]);
+
+  // 返回操作面板
   const handleReturn = () => {
     dispatch(switchToOperationPanel());
   };
 
+  // 全选 viewers
+  const handleSelectAllViewers = () => {
+    dispatch(selectAllViewers());
+  };
+
+  // 取消选中所有 viewers
+  const handleDeselectAllViewers = () => {
+    dispatch(deselectAllViewers());
+  };
+
+  // 切换节点选中状态
+  const handleNodeToggle = (nodeId: number) => {
+    dispatch(toggleNodeSelection(nodeId));
+  };
+
+  // 全选/取消全选节点
+  const handleSelectAllNodes = (checked: boolean) => {
+    if (checked) {
+      dispatch(selectAllNodes());
+    } else {
+      dispatch(deselectAllNodes());
+    }
+  };
+
+  // 重新加载节点列表
+  const handleReload = () => {
+    dispatch(fetchPacsNodesThunk());
+  };
+
+  // 执行发送逻辑
+  const performSend = async (
+    sopInstanceUids: string[],
+    pacsNames: string[]
+  ) => {
+    const total = sopInstanceUids.length * pacsNames.length;
+
+    // 初始化进度
+    setSendingProgress({
+      isSending: true,
+      total,
+      completed: 0,
+      failed: 0,
+    });
+
+    let completed = 0;
+    let failed = 0;
+
+    try {
+      // 双层循环发送
+      for (const sopUid of sopInstanceUids) {
+        for (const pacsName of pacsNames) {
+          try {
+            await sendImageToPacs(sopUid, pacsName);
+            completed++;
+            setSendingProgress({
+              isSending: true,
+              total,
+              completed,
+              failed,
+            });
+          } catch (error) {
+            failed++;
+            setSendingProgress({
+              isSending: true,
+              total,
+              completed,
+              failed,
+            });
+            console.error(
+              `发送失败 [SOP: ${sopUid} -> PACS: ${pacsName}]:`,
+              error
+            );
+          }
+        }
+      }
+
+      // 显示结果
+      if (failed === 0) {
+        message.success(`发送完成!成功发送 ${completed} 个任务`);
+      } else {
+        message.warning(
+          `发送完成!成功 ${completed} 个,失败 ${failed} 个`
+        );
+      }
+    } finally {
+      // 重置发送状态
+      setSendingProgress({
+        isSending: false,
+        total: 0,
+        completed: 0,
+        failed: 0,
+      });
+    }
+  };
+
+  // 发送图像
+  const handleSend = () => {
+    // 验证 viewer 选择
+    if (selectedViewers.length === 0) {
+      message.warning('请先选择要发送的图像');
+      return;
+    }
+
+    // 验证节点选择
+    if (selectedNodeIds.length === 0) {
+      message.warning('请至少选择一个PACS节点');
+      return;
+    }
+
+    // 提取 SOP Instance UIDs
+    const sopInstanceUids = selectedViewers
+      .map(extractSopInstanceUid)
+      .filter((uid) => uid !== ''); // 过滤无效 UID
+
+    if (sopInstanceUids.length === 0) {
+      message.error('无法提取图像 UID,请检查图像数据');
+      return;
+    }
+
+    // 获取节点名称
+    const selectedNodes = nodes.filter((node) =>
+      selectedNodeIds.includes(node.id)
+    );
+    const pacsNames = selectedNodes.map((node) => node.name);
+    const nodeNames = pacsNames.join(', ');
+
+    // 显示确认对话框
+    Modal.confirm({
+      title: '确认发送图像',
+      content: (
+        <div>
+          <p>
+            即将发送 <strong>{sopInstanceUids.length}</strong> 个图像到以下
+            PACS 节点:
+          </p>
+          <p style={{ marginTop: 8, color: '#1890ff' }}>{nodeNames}</p>
+          <p style={{ marginTop: 8 }}>
+            共{' '}
+            <strong>{sopInstanceUids.length * pacsNames.length}</strong>{' '}
+            个发送任务
+          </p>
+        </div>
+      ),
+      okText: '确认发送',
+      cancelText: '取消',
+      okButtonProps: { danger: true },
+      onOk: async () => {
+        await performSend(sopInstanceUids, pacsNames);
+      },
+    });
+  };
+
+  // 是否全选节点
+  const isAllNodesSelected =
+    nodes.length > 0 &&
+    nodes.filter((node) => node.is_enabled).every((node) =>
+      selectedNodeIds.includes(node.id)
+    );
+
+  // 是否部分选中节点
+  const isNodesIndeterminate =
+    selectedNodeIds.length > 0 && !isAllNodesSelected;
+
   return (
     <Layout className="h-full">
       {/* 顶部导航栏 */}
@@ -29,45 +251,204 @@ 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="重新加载PACS节点"
         />
-        <Title level={5} style={{ margin: 0, lineHeight: '48px' }}>
-          发送图像
-        </Title>
       </Header>
 
       {/* 主体内容 */}
       <Content
         style={{ padding: '16px', maxHeight: '100%', overflowY: 'auto' }}
       >
-        <Space style={{ marginBottom: '16px' }}>
-          <Button icon={<PictureOutlined />} />
-          <Button icon={<CheckSquareOutlined />} />
-          <Button icon={<CloseSquareOutlined />} />
-        </Space>
-        <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>
-          )}
-        />
+        {/* 发送进度 */}
+        {sendingProgress.isSending && (
+          <div
+            style={{
+              marginBottom: 16,
+              padding: 16,
+              backgroundColor: '#f0f5ff',
+              borderRadius: 4,
+            }}
+          >
+            <div style={{ marginBottom: 8, fontWeight: 500 }}>
+              正在发送: {sendingProgress.completed + sendingProgress.failed} /{' '}
+              {sendingProgress.total}
+            </div>
+            <Progress
+              percent={Math.round(
+                ((sendingProgress.completed + sendingProgress.failed) /
+                  sendingProgress.total) *
+                  100
+              )}
+              status={sendingProgress.failed > 0 ? 'exception' : 'active'}
+              strokeColor={sendingProgress.failed > 0 ? '#ff4d4f' : '#1890ff'}
+            />
+            <div
+              style={{
+                marginTop: 8,
+                fontSize: '12px',
+                color: '#666',
+                display: 'flex',
+                justifyContent: 'space-between',
+              }}
+            >
+              <span>成功: {sendingProgress.completed}</span>
+              <span style={{ color: '#ff4d4f' }}>
+                失败: {sendingProgress.failed}
+              </span>
+            </div>
+          </div>
+        )}
+
+        {/* Viewer 选择按钮 */}
+        <div style={{ marginBottom: 16 }}>
+          <Space>
+            <Button
+              icon={<CheckSquareOutlined />}
+              onClick={handleSelectAllViewers}
+              disabled={sendingProgress.isSending}
+            >
+              全选图像
+            </Button>
+            <Button
+              icon={<CloseSquareOutlined />}
+              onClick={handleDeselectAllViewers}
+              disabled={sendingProgress.isSending}
+            >
+              取消选中
+            </Button>
+            <span style={{ marginLeft: 8, color: '#666' }}>
+              已选中 {selectedViewers.length} 个图像
+            </span>
+          </Space>
+        </div>
+
+        {/* 错误提示 */}
+        {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={isNodesIndeterminate}
+                    checked={isAllNodesSelected}
+                    onChange={(e) => handleSelectAllNodes(e.target.checked)}
+                    disabled={sendingProgress.isSending}
+                  >
+                    全选节点 ({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 || sendingProgress.isSending}
+                      >
+                        <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>
 
       {/* 底部按钮 */}
@@ -91,13 +472,22 @@ const SendImagePage = () => {
             />
           }
           style={{ width: '100%', maxWidth: '400px' }}
-          onClick={() => alert('发送图像')}
+          onClick={handleSend}
+          disabled={
+            selectedViewers.length === 0 ||
+            selectedNodeIds.length === 0 ||
+            loading ||
+            sendingProgress.isSending
+          }
+          loading={sendingProgress.isSending}
         >
-          发送图像
+          {sendingProgress.isSending
+            ? `发送中... (${sendingProgress.completed + sendingProgress.failed}/${sendingProgress.total})`
+            : `发送图像 (${selectedNodeIds.length})`}
         </Button>
       </Footer>
     </Layout>
   );
 };
 
-export default SendImagePage;
+export default SendPanelForView;

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

@@ -140,6 +140,13 @@ const viewerContainerSlice = createSlice({
       state.selectedViewers = [...state.allViewers];
     },
 
+    /**
+     * 取消选中所有 viewers
+     */
+    deselectAllViewers: (state) => {
+      state.selectedViewers = [];
+    },
+
     /**
      * 重置 ViewerContainer 状态
      */
@@ -162,6 +169,7 @@ export const {
   removeFromSelection,
   clearSelection,
   selectAllViewers,
+  deselectAllViewers,
   resetViewerContainer,
 } = viewerContainerSlice.actions;