# 发送图像功能 - 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: (

即将发送 {sopInstanceUids.length} 个图像到以下 PACS 节点:

{nodeNames}

{sopInstanceUids.length * pacsNames.length} 个发送任务

), 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 选