发送图像功能-ViewMode.md 25 KB

发送图像功能 - 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 核心类型/接口

// 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

// viewerContainerSlice
- selectAllViewers()          // 选中所有 viewers
- deselectAllViewers()         // 取消选中所有 viewers (新增)
- setSelectedViewers(urls[])   // 设置选中的 viewers

// pacsNodeSlice
- fetchPacsNodesThunk()        // 异步获取 PACS 节点列表
- toggleNodeSelection(id)      // 切换节点选中状态
- selectAllNodes()             // 全选节点
- deselectAllNodes()           // 取消全选节点

// panelSwitchSliceForView
- switchToSendPanel()          // 切换到发送面板
- switchToOperationPanel()     // 返回操作面板

组件方法

// 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

      deselectAllViewers: (state) => {
      state.selectedViewers = [];
      }
      
      1. 实现文档
      2. 文件:docs/实现/发送图像功能-ViewMode.md(本文档)
      3. 内容:完整的功能实现文档

      3.2 需要修改的内容

      1. SendPanelForView.tsx - 主要实现文件
      2. 导入必要的依赖
      3. 实现状态管理
      4. 实现 UI 渲染
      5. 实现业务逻辑

      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 节点状态示例

{
  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 发送进度状态示例

// 发送中
{
  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 映射

// 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 提取函数

/**
 * 从 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 全选/取消选中实现

// 全选
const handleSelectAllViewers = () => {
  dispatch(selectAllViewers());
};

// 取消选中
const handleDeselectAllViewers = () => {
  dispatch(deselectAllViewers());
};

8.3 发送图像核心逻辑

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 发送按钮点击处理

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 选