瀏覽代碼

feat: 实现发送图像功能,支持批量发送Study到PACS节点

- 在 SendPanel.tsx 中实现完整的发送图像UI和逻辑
- 新增 sendStudyToPacs API 支持按Study批量发送
- 添加发送进度显示、确认对话框和错误处理
- 实现从 historySelection 获取选中的 Study IDs
- 支持多节点批量发送,显示实时进度和统计
- 创建完整的功能实现文档

改动文件:
- src/pages/output/SendPanel.tsx
- src/API/imageActions.ts
- docs/实现/发送图像功能.md
- docs/DR.md
sw 3 天之前
父節點
當前提交
e42e649264
共有 4 個文件被更改,包括 787 次插入49 次删除
  1. 77 41
      docs/DR.md
  2. 526 0
      docs/实现/发送图像功能.md
  3. 40 0
      src/API/imageActions.ts
  4. 144 8
      src/pages/output/SendPanel.tsx

+ 77 - 41
docs/DR.md

@@ -1985,46 +1985,48 @@ zh_CN
 ● 200: OK 响应数据格式:JSON
 ```json
 {
-  "code": "0x000000",
-  "description": "Success",
-  "solution": "",
-  "data": {
-    "@type": "type.googleapis.com/dr.study.ScpList",
-    "scp": [
-      {
-        "id": 3,
-        "name": "pacs2",
-        "type": "PACS",
-        "address": "192.168.1.4",
-        "port": 6299,
-        "aet": "testscp",
-        "aec": "testscu",
-        "is_enabled": true,
-        "is_default": false,
-        "params": {
-          "a": "b"
-        }
-      },
-      {
-        "id": 1,
-        "name": "pacs1",
-        "type": "PACS",
-        "address": "192.168.1.3",
-        "port": 6299,
-        "aet": "testscp",
-        "aec": "testscu",
-        "is_enabled": true,
-        "is_default": false,
-        "params": {
-          "a": "b"
-        }
-      }
-    ]
-  }
+	"code": "0x000000",
+	"description": "Success",
+	"solution": "",
+	"data": {
+		"@type": "type.googleapis.com/dr.study.ScpList",
+		"scp": [
+			{
+				"id": 5,
+				"name": "pacs3",
+				"type": "PACS",
+				"address": "192.168.110.193",
+				"port": 104,
+				"aet": "testscp",
+				"aec": "testscu",
+				"is_enabled": true,
+				"is_default": false,
+				"params": {
+					"debug": false,
+					"verbose": true
+				}
+			},
+			{
+				"id": 3,
+				"name": "pacs2",
+				"type": "PACS",
+				"address": "192.168.1.4",
+				"port": 6299,
+				"aet": "testscp",
+				"aec": "testscu",
+				"is_enabled": true,
+				"is_default": false,
+				"params": {
+					"debug": false,
+					"verbose": false
+				}
+			}
+		]
+	}
 }
 ```
 请求示例
-
+![  ](http://f1-xyj.fangdeco.cn/attachment/2025/10/11/2M2HRxPLai0/761eb4441cfe4a5aa308cdf1859d32c3)
 
 ## 15.3	获取发送队列(PACS)
 
@@ -2081,7 +2083,7 @@ zh_CN
 请求示例
 ![  ](http://f1-xyj.fangdeco.cn/attachment/2025/10/10/2M0ruPn3TXs/93dd3fd6fb9547379918d93652c9151e)
 
-## 15.4	删除发送任务
+## 15.4	删除发送任务(批量)
 
 > DELETE  /api/v1/auth/scp/task
 ### 接口说明
@@ -2104,7 +2106,7 @@ zh_CN
 }
 ```
 请求示例
-![  ](http://f1-xyj.fangdeco.cn/attachment/2025/10/10/2M0sbMgtryy/b22276514372464c84e062c76a47cbe3)
+![  ](http://f1-xyj.fangdeco.cn/attachment/2025/10/11/2M2HVKxHy8u/3d8b16fc08844c80930402f17e389dc2)
 
 
 ## 15.5	发送图像
@@ -2141,7 +2143,41 @@ zh_CN
 请求示例
 ![  ](http://f1-xyj.fangdeco.cn/attachment/2025/10/10/2M0jbE69VxY/be2c33a1b0e344f1954927b726b18fa4)
 
-## 15.6	重试发送任务
+## 15.6	发送图像(按Study批量发送)
+
+> POST  /api/v1/auth/scp/study_store
+### 接口说明
+> 发送图像(按Study批量发送)
+### 请求头
+| 参数名称 | 默认值 | 描述 |
+| ------ | ------ | ------ |
+|Authorization|Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTEyNzc5NzAsImlkIjoxLCJuYW1lIjoiYWRtaW4ifQ.ooTGwBXaNhtunbKbpqteWbjDwJLjnRmSIl80r5dp1pY||
+|Language||en_US 或 zh_CN|
+|Product|DROS|DROS 或 VETDROS|
+|Source|Electron|Electron 或 Browser 或 Android|
+### 请求体(Request Body)
+| 参数名称 | 数据类型 | 默认值 | 不为空 | 描述 |
+| ------ | ------ | ------ | ------ | ------ |
+| instance_uid|string||true|study_id|
+| pacs_name|string||true|pacs节点名称|
+### 响应体
+● 200: OK 响应数据格式:JSON
+```json
+{
+  "code": "0x000000",
+  "description": "Success",
+  "solution": "",
+  "data": {
+    "@type": "type.googleapis.com/dr.task.StoreReply",
+    "ok": true,
+    "output": "...D: ======================= END DIMSE MESSAGE =======================\nI: Releasing Association\n"
+  }
+}
+```
+请求示例
+![  ](http://f1-xyj.fangdeco.cn/attachment/2025/10/10/2M0jbE69VxY/be2c33a1b0e344f1954927b726b18fa4)
+
+## 15.7	重试发送任务
 
 > POST  /api/v1/auth/scp/store_reply
 ### 接口说明
@@ -2172,7 +2208,7 @@ zh_CN
 }
 ```
 请求示例
-![  ](http://f1-xyj.fangdeco.cn/attachment/2025/10/10/2M0suruwmDA/2ed499a43c564e338ed8cf302fd1e0e6)
+![  ](http://f1-xyj.fangdeco.cn/attachment/2025/10/11/2M2HYvSokoS/13f07b5d144c465daff5ca8b70cc82c2)
 
 ## 16	登记检查信息
 

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

@@ -0,0 +1,526 @@
+# 发送图像功能实现文档
+
+## 功能概述
+
+本文档描述了发送图像功能的完整实现方案。该功能允许用户将选中的 Study 发送到一个或多个 PACS 节点。
+
+## 业务流程
+
+```
+用户选择Study(在history表格中)
+    ↓
+打开发送面板(SendPanel)
+    ↓
+系统自动加载并显示PACS节点列表
+    ↓
+系统自动选中默认节点
+    ↓
+用户选择目标PACS节点(可多选)
+    ↓
+点击"发送图像"按钮
+    ↓
+显示确认对话框
+    ↓
+用户确认
+    ↓
+批量发送到所有选中的PACS节点
+    ↓
+显示发送进度
+    ↓
+发送完成,显示结果统计
+    ↓
+停留在发送面板(不自动返回)
+```
+
+## 参与者清单
+
+### 1. UI 组件层
+
+#### SendPanel.tsx
+- **路径**: `src/pages/output/SendPanel.tsx`
+- **职责**:
+  - 显示 PACS 节点列表
+  - 处理节点选择
+  - 显示发送进度
+  - 执行发送操作
+
+### 2. 状态管理层
+
+#### pacsNodeSlice
+- **路径**: `src/states/output/pacsNode/pacsNodeSlice.ts`
+- **职责**:
+  - 管理 PACS 节点列表
+  - 管理节点选择状态
+  - 自动选择默认节点
+
+#### historySelectionSlice
+- **路径**: `src/states/patient/worklist/slices/history.ts`
+- **职责**:
+  - 管理 history 表格的选中状态
+  - 提供选中的 Study IDs
+
+#### historyPanelSwitchSlice
+- **路径**: `src/states/patient/worklist/slices/historyPanelSwitchSlice.ts`
+- **职责**:
+  - 管理面板切换状态
+  - 切换到发送面板/操作面板
+
+### 3. API 层
+
+#### imageActions.ts
+- **路径**: `src/API/imageActions.ts`
+- **关键方法**:
+  - `sendStudyToPacs(studyId: string, pacsName: string)`: 按 Study 批量发送图像
+
+#### pacsNodeActions.ts
+- **路径**: `src/API/output/pacsNodeActions.ts`
+- **关键方法**:
+  - `getPacsNodeList()`: 获取 PACS 节点列表
+
+### 4. 领域模型层
+
+#### PacsNode
+```typescript
+interface PacsNode {
+  id: number;
+  name: string;           // 节点名称,发送时使用
+  type: string;
+  address: string;
+  port: number;
+  aet: string;            // AE Title
+  aec: string;            // AE Calling
+  is_enabled: boolean;    // 是否启用
+  is_default: boolean;    // 是否为默认节点
+}
+```
+
+## 数据流
+
+### 1. 初始化流程
+
+```
+SendPanel 组件挂载
+    ↓
+useEffect 触发
+    ↓
+dispatch(fetchPacsNodesThunk())
+    ↓
+调用 getPacsNodeList() API
+    ↓
+返回 PacsNode[] 数据
+    ↓
+pacsNodeSlice 更新 state
+    ↓
+自动选中 is_default=true 的节点
+    ↓
+UI 更新,显示节点列表
+```
+
+### 2. 发送流程
+
+```
+用户点击"发送图像"按钮
+    ↓
+handleSend() 被调用
+    ↓
+从 store 获取数据:
+  - historySelection.selectedIds (Study IDs)
+  - pacsNode.selectedNodeIds (PACS 节点 IDs)
+  - pacsNode.nodes (节点详细信息)
+    ↓
+验证数据有效性
+    ↓
+显示确认对话框
+    ↓
+用户确认
+    ↓
+performSend() 被调用
+    ↓
+转换节点 ID 为节点名称
+    ↓
+计算总任务数 = studyIds.length × pacsNames.length
+    ↓
+初始化发送进度状态
+    ↓
+循环发送:
+  for studyId in selectedStudyIds:
+    for pacsName in selectedPacsNames:
+      调用 sendStudyToPacs(studyId, pacsName)
+      更新进度 (completed/failed)
+    ↓
+发送完成
+    ↓
+显示结果统计
+    ↓
+重置进度状态
+```
+
+### 3. 数据获取方式
+
+```typescript
+// ✅ 正确方式:在事件处理器中使用 store.getState()
+const handleSend = () => {
+  const selectedStudyIds = store.getState().historySelection.selectedIds;
+  // ...
+};
+
+// ❌ 错误方式:在事件处理器中使用 hook
+const handleSend = () => {
+  // const selectedStudyIds = useAppSelector(...); // 这样会报错
+};
+```
+
+## 组件交互泳道图
+
+```
+用户          SendPanel组件        pacsNodeSlice        historySelection        API
+│                │                    │                      │                   │
+├─选择Study─────>│                    │                      │                   │
+│                │                    │                      ├─更新selectedIds   │
+│                │                    │                      │                   │
+├─打开发送面板──>│                    │                      │                   │
+│                │                    │                      │                   │
+│                ├─挂载,dispatch─────>│                      │                   │
+│                │  fetchPacsNodes   │                      │                   │
+│                │                    ├─调用API─────────────────────────────────>│
+│                │                    │                      │                   │
+│                │                    │<─返回PacsNode[]──────────────────────────┤
+│                │                    │                      │                   │
+│                │                    ├─自动选中默认节点      │                   │
+│                │                    │                      │                   │
+│                │<─渲染节点列表───────┤                      │                   │
+│                │                    │                      │                   │
+├─勾选节点───────>│                    │                      │                   │
+│                ├─dispatch─────────>│                      │                   │
+│                │  toggleSelection  │                      │                   │
+│                │                    ├─更新selectedNodeIds  │                   │
+│                │                    │                      │                   │
+├─点击发送───────>│                    │                      │                   │
+│                │                    │                      │                   │
+│                ├─获取数据:          │                      │                   │
+│                │  store.getState() │                      │                   │
+│                │    .historySelection                      │                   │
+│                │    .pacsNode       │                      │                   │
+│                │                    │                      │                   │
+│                ├─显示确认对话框      │                      │                   │
+│                │                    │                      │                   │
+├─确认───────────>│                    │                      │                   │
+│                │                    │                      │                   │
+│                ├─循环调用sendStudyToPacs()─────────────────────────────────────>│
+│                │                    │                      │                   │
+│                │                    │                      │<─返回结果─────────┤
+│                │                    │                      │                   │
+│                ├─更新进度状态        │                      │                   │
+│                │                    │                      │                   │
+│                ├─显示完成消息        │                      │                   │
+│                │                    │                      │                   │
+├─查看结果───────>│                    │                      │                   │
+│  (停留在面板)  │                    │                      │                   │
+```
+
+## 核心代码实现
+
+### 1. 发送进度状态
+
+```typescript
+const [sendingProgress, setSendingProgress] = useState({
+  isSending: false,      // 是否正在发送
+  total: 0,              // 总任务数
+  completed: 0,          // 已完成数
+  failed: 0,             // 失败数
+});
+```
+
+### 2. 发送逻辑实现
+
+```typescript
+const performSend = async (studyIds: string[], nodeIds: number[]) => {
+  // 获取节点名称
+  const selectedNodes = nodes.filter(node => nodeIds.includes(node.id));
+  const pacsNames = selectedNodes.map(node => node.name);
+  
+  // 计算总任务数
+  const total = studyIds.length * pacsNames.length;
+  
+  // 初始化进度
+  setSendingProgress({
+    isSending: true,
+    total,
+    completed: 0,
+    failed: 0,
+  });
+  
+  let completed = 0;
+  let failed = 0;
+  
+  try {
+    // 双层循环发送
+    for (const studyId of studyIds) {
+      for (const pacsName of pacsNames) {
+        try {
+          await sendStudyToPacs(studyId, pacsName);
+          completed++;
+          setSendingProgress({ isSending: true, total, completed, failed });
+        } catch (error) {
+          failed++;
+          setSendingProgress({ isSending: true, total, completed, failed });
+          console.error(`发送失败 [Study: ${studyId} -> PACS: ${pacsName}]:`, error);
+        }
+      }
+    }
+    
+    // 显示结果
+    if (failed === 0) {
+      message.success(`发送完成!成功发送 ${completed} 个任务`);
+    } else {
+      message.warning(`发送完成!成功 ${completed} 个,失败 ${failed} 个`);
+    }
+  } finally {
+    // 重置状态
+    setSendingProgress({ isSending: false, total: 0, completed: 0, failed: 0 });
+  }
+};
+```
+
+### 3. 发送按钮处理器
+
+```typescript
+const handleSend = async () => {
+  // 验证节点选择
+  if (selectedNodeIds.length === 0) {
+    message.warning('请至少选择一个PACS节点');
+    return;
+  }
+  
+  // ✅ 从 store 获取选中的 Study IDs
+  const selectedStudyIds = store.getState().historySelection.selectedIds;
+  
+  // 验证 Study 选择
+  if (selectedStudyIds.length === 0) {
+    message.warning('请先选择要发送的 Study');
+    return;
+  }
+  
+  // 获取选中节点名称
+  const selectedNodes = nodes.filter(node => selectedNodeIds.includes(node.id));
+  const nodeNames = selectedNodes.map(node => node.name).join(', ');
+  
+  // 显示确认对话框
+  Modal.confirm({
+    title: '确认发送图像',
+    content: (
+      <div>
+        <p>即将发送 <strong>{selectedStudyIds.length}</strong> 个 Study 到以下 PACS 节点:</p>
+        <p style={{ marginTop: 8, color: '#1890ff' }}>{nodeNames}</p>
+        <p style={{ marginTop: 8 }}>共 <strong>{selectedStudyIds.length * selectedNodeIds.length}</strong> 个发送任务</p>
+      </div>
+    ),
+    okText: '确认发送',
+    cancelText: '取消',
+    okButtonProps: { danger: true },
+    onOk: async () => {
+      await performSend(selectedStudyIds, selectedNodeIds);
+    },
+  });
+};
+```
+
+### 4. 进度条 UI
+
+```typescript
+{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>
+)}
+```
+
+## 关键技术决策
+
+### 1. 状态管理策略
+
+**问题**: 发送进度是否需要放在 Redux 中?
+
+**决策**: 使用组件内部 `useState` 管理
+
+**理由**:
+- 发送状态是临时的,不需要跨组件共享
+- 组件卸载时状态自动清理
+- 避免增加 Redux 复杂度
+
+### 2. 数据获取方式
+
+**问题**: 如何在事件处理器中获取 Redux 状态?
+
+**决策**: 使用 `store.getState()` 而不是 `useAppSelector`
+
+**理由**:
+- `useAppSelector` 是 Hook,只能在组件顶层使用
+- 事件处理器是普通函数,需要使用 `store.getState()`
+- 这是 Redux 官方推荐的方式
+
+### 3. 错误处理策略
+
+**问题**: 单个发送任务失败时如何处理?
+
+**决策**: 记录失败但继续发送其他任务
+
+**理由**:
+- 提高用户体验,不因单个失败阻塞全部
+- 最终展示成功/失败统计,用户可以看到全貌
+- 失败详情记录在控制台,方便调试
+
+### 4. 发送策略
+
+**问题**: 串行还是并行发送?
+
+**决策**: 串行发送(一个接一个)
+
+**理由**:
+- 避免并发请求过多导致服务器压力
+- 便于跟踪进度和错误
+- 保证发送顺序可预测
+- 未来可优化为半并发(如 3 个并发)
+
+### 5. 用户体验设计
+
+**问题**: 发送完成后是否自动返回?
+
+**决策**: 停留在发送面板
+
+**理由**:
+- 用户可以查看发送结果
+- 避免信息丢失
+- 用户自主决定何时返回
+
+## 功能特性
+
+### ✅ 已实现
+
+1. **PACS 节点管理**
+   - 自动加载节点列表
+   - 显示节点详细信息(地址、端口、AET)
+   - 支持启用/禁用状态
+   - 默认节点标注
+
+2. **节点选择**
+   - 单选/多选支持
+   - 全选/取消全选
+   - 自动选中默认节点
+   - 禁用已禁用的节点
+
+3. **发送功能**
+   - 批量发送 Study
+   - 发送前确认对话框
+   - 实时进度显示
+   - 成功/失败统计
+   - 错误容错处理
+
+4. **用户体验**
+   - 加载状态提示
+   - 错误信息展示
+   - 刷新功能
+   - 返回功能
+
+### 📋 待优化
+
+1. **性能优化**
+   - 支持并发发送(控制并发数)
+   - 大批量发送时的性能优化
+
+2. **功能增强**
+   - 支持发送任务队列
+   - 支持暂停/恢复发送
+   - 支持查看发送历史
+
+3. **用户体验**
+   - 发送详情展开/折叠
+   - 失败任务重试功能
+   - 导出发送报告
+
+## 测试要点
+
+### 1. 功能测试
+
+- [ ] 打开发送面板,验证节点列表加载
+- [ ] 验证默认节点自动选中
+- [ ] 选择/取消选择节点
+- [ ] 全选/取消全选功能
+- [ ] 未选择节点时点击发送,验证提示
+- [ ] 未选择 Study 时点击发送,验证提示
+- [ ] 正常发送流程,验证确认对话框
+- [ ] 确认发送,验证进度显示
+- [ ] 验证发送成功提示
+- [ ] 验证发送失败处理
+- [ ] 验证停留在发送面板
+- [ ] 点击返回按钮,验证返回到操作面板
+
+### 2. 边界测试
+
+- [ ] 0 个 Study 发送
+- [ ] 1 个 Study 发送
+- [ ] 多个 Study 发送
+- [ ] 所有节点禁用时的表现
+- [ ] 网络异常时的表现
+- [ ] 发送过程中刷新页面
+
+### 3. 性能测试
+
+- [ ] 大量 Study (>100) 发送性能
+- [ ] 多节点 (>10) 同时发送性能
+- [ ] 进度更新频率对性能的影响
+
+## 注意事项
+
+1. **数据源**
+   - 必须从 `historySelection` 获取 Study IDs
+   - 不要混淆 `workSelection` 和 `historySelection`
+
+2. **Hook 使用**
+   - 不要在事件处理器中使用 `useAppSelector`
+   - 在事件处理器中使用 `store.getState()`
+
+3. **错误处理**
+   - 单个发送失败不应阻止其他发送
+   - 需要记录所有错误信息
+
+4. **用户提示**
+   - 发送前必须有确认对话框
+   - 发送完成必须有结果提示
+   - 进度必须实时更新
+
+## 相关文件
+
+- `src/pages/output/SendPanel.tsx` - 发送面板组件
+- `src/states/output/pacsNode/pacsNodeSlice.ts` - PACS 节点状态管理
+- `src/states/patient/worklist/slices/history.ts` - History 列表状态管理
+- `src/API/imageActions.ts` - 图像发送 API
+- `src/API/output/pacsNodeActions.ts` - PACS 节点 API
+
+## 更新记录
+
+- 2025-10-11: 初始版本,完成基本发送功能设计

+ 40 - 0
src/API/imageActions.ts

@@ -75,3 +75,43 @@ export const sendImageToPacs = async (
     throw error;
   }
 };
+
+/**
+ * 按 Study 批量发送图像到 PACS 节点
+ * @param studyId Study 实例 UID (Study Instance UID)
+ * @param pacsName PACS 节点名称
+ * @returns 发送结果,包含成功状态和输出信息
+ * @throws 当发送失败时抛出错误
+ * 
+ * @example
+ * ```typescript
+ * const result = await sendStudyToPacs(
+ *   '1.2.276.0.1000000.5.1.2.701601461.33458.1750833219.482097',
+ *   'pacs1'
+ * );
+ * console.log('发送成功:', result.data.ok);
+ * ```
+ */
+export const sendStudyToPacs = async (
+  studyId: string,
+  pacsName: string
+): Promise<SendImageResponse> => {
+  try {
+    const response = await axiosInstance.post<SendImageResponse>(
+      '/api/v1/auth/scp/study_store',
+      {
+        instance_uid: studyId,
+        pacs_name: pacsName,
+      }
+    );
+
+    if (response.data.code !== '0x000000') {
+      throw new Error(`按 Study 发送图像失败: ${response.data.description}`);
+    }
+
+    return response.data;
+  } catch (error) {
+    console.error('Error sending study to PACS:', error);
+    throw error;
+  }
+};

+ 144 - 8
src/pages/output/SendPanel.tsx

@@ -1,5 +1,5 @@
-import React, { useEffect } from 'react';
-import { Layout, Button, Checkbox, Typography, List, Spin, Alert, message } from 'antd';
+import React, { useEffect, useState } from 'react';
+import { Layout, Button, Checkbox, Typography, List, Spin, Alert, message, Modal, Progress } from 'antd';
 import { ArrowLeftOutlined, ReloadOutlined } from '@ant-design/icons';
 import { useDispatch, useSelector } from 'react-redux';
 import { switchToOperationPanel } from '@/states/patient/worklist/slices/historyPanelSwitchSlice';
@@ -11,6 +11,8 @@ import {
 } from '@/states/output/pacsNode/pacsNodeSlice';
 import { RootState, AppDispatch } from '@/states/store';
 import Icon from '@/components/Icon';
+import { sendStudyToPacs } from '@/API/imageActions';
+import store from '@/states/store';
 
 const { Header, Content, Footer } = Layout;
 const { Title } = Typography;
@@ -23,6 +25,14 @@ const SendImagePage = () => {
     (state: RootState) => state.pacsNode
   );
 
+  // 发送进度状态
+  const [sendingProgress, setSendingProgress] = useState({
+    isSending: false,
+    total: 0,
+    completed: 0,
+    failed: 0,
+  });
+
   // 组件挂载时获取PACS节点列表
   useEffect(() => {
     dispatch(fetchPacsNodesThunk());
@@ -52,16 +62,107 @@ const SendImagePage = () => {
     dispatch(fetchPacsNodesThunk());
   };
 
+  // 执行发送逻辑
+  const performSend = async (studyIds: string[], nodeIds: number[]) => {
+    // 获取节点名称
+    const selectedNodes = nodes.filter(node => nodeIds.includes(node.id));
+    const pacsNames = selectedNodes.map(node => node.name);
+    
+    // 计算总任务数
+    const total = studyIds.length * pacsNames.length;
+    
+    // 初始化进度
+    setSendingProgress({
+      isSending: true,
+      total,
+      completed: 0,
+      failed: 0,
+    });
+    
+    let completed = 0;
+    let failed = 0;
+    
+    try {
+      // 遍历发送
+      for (const studyId of studyIds) {
+        for (const pacsName of pacsNames) {
+          try {
+            await sendStudyToPacs(studyId, pacsName);
+            completed++;
+            setSendingProgress({
+              isSending: true,
+              total,
+              completed,
+              failed,
+            });
+          } catch (error) {
+            failed++;
+            setSendingProgress({
+              isSending: true,
+              total,
+              completed,
+              failed,
+            });
+            console.error(`发送失败 [Study: ${studyId} -> 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 = () => {
+    // 验证节点选择
     if (selectedNodeIds.length === 0) {
       message.warning('请至少选择一个PACS节点');
       return;
     }
     
-    // TODO: 实现实际的发送图像功能
-    message.info(`准备发送到 ${selectedNodeIds.length} 个节点`);
-    console.log('Selected node IDs:', selectedNodeIds);
+    // 从 store 获取选中的 Study IDs
+    const selectedStudyIds = store.getState().historySelection.selectedIds;
+    
+    // 验证 Study 选择
+    if (selectedStudyIds.length === 0) {
+      message.warning('请先选择要发送的 Study');
+      return;
+    }
+    
+    // 获取选中节点名称
+    const selectedNodes = nodes.filter(node => selectedNodeIds.includes(node.id));
+    const nodeNames = selectedNodes.map(node => node.name).join(', ');
+    
+    // 显示确认对话框
+    Modal.confirm({
+      title: '确认发送图像',
+      content: (
+        <div>
+          <p>即将发送 <strong>{selectedStudyIds.length}</strong> 个 Study 到以下 PACS 节点:</p>
+          <p style={{ marginTop: 8, color: '#1890ff' }}>{nodeNames}</p>
+          <p style={{ marginTop: 8 }}>共 <strong>{selectedStudyIds.length * selectedNodeIds.length}</strong> 个发送任务</p>
+        </div>
+      ),
+      okText: '确认发送',
+      cancelText: '取消',
+      okButtonProps: { danger: true },
+      onOk: async () => {
+        await performSend(selectedStudyIds, selectedNodeIds);
+      },
+    });
   };
 
   // 是否全选
@@ -106,6 +207,37 @@ const SendImagePage = () => {
       <Content
         style={{ padding: '16px', maxHeight: '100%', overflowY: 'auto' }}
       >
+        {/* 发送进度 */}
+        {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>
+        )}
+
         {/* 错误提示 */}
         {error && (
           <Alert
@@ -168,7 +300,7 @@ const SendImagePage = () => {
                             {node.name}
                             {node.is_default && (
                               <span style={{ marginLeft: 8, color: '#1890ff', fontSize: '12px' }}>
-                                [默认]
+                                (默认节点)
                               </span>
                             )}
                             {!node.is_enabled && (
@@ -213,9 +345,13 @@ const SendImagePage = () => {
           }
           style={{ width: '100%', maxWidth: '400px' }}
           onClick={handleSend}
-          disabled={selectedNodeIds.length === 0 || loading}
+          disabled={selectedNodeIds.length === 0 || loading || sendingProgress.isSending}
+          loading={sendingProgress.isSending}
         >
-          发送图像 ({selectedNodeIds.length})
+          {sendingProgress.isSending 
+            ? `发送中... (${sendingProgress.completed + sendingProgress.failed}/${sendingProgress.total})`
+            : `发送图像 (${selectedNodeIds.length})`
+          }
         </Button>
       </Footer>
     </Layout>