在图像查看页面(View Mode)中,用户可以选择当前查看的图像并将其发送到指定的 PACS 节点。本功能提供了一个发送面板,允许用户:
getDcmImageUrl
函数// 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;
}
// 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
新增 Redux Action
src/states/view/viewerContainerSlice.ts
内容:添加 deselectAllViewers
reducer
deselectAllViewers: (state) => {
state.selectedViewers = [];
}
docs/实现/发送图像功能-ViewMode.md
(本文档)所有现有代码保持不变,仅进行功能扩展。
```
┌─────────┐ ┌──────────────┐ ┌──────────────────┐ ┌─────────────┐ ┌─────┐ │ 用户 │ │ 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"
]
}
{
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
}
// 发送中
{
isSending: true,
total: 6, // 2 images × 3 PACS nodes
completed: 4, // 已完成 4 个
failed: 1 // 失败 1 个
}
// 发送完成
{
isSending: false,
total: 0,
completed: 0,
failed: 0
}
// 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"
第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 个"
用户可以:
→ 继续发送其他图像
→ 点击返回按钮回到操作面板
场景1: 未选择 viewer
用户点击"发送图像"
→ 显示警告: "请先选择要发送的图像"
→ 停留在当前面板
场景2: 未选择 PACS 节点
用户点击"发送图像"
→ 显示警告: "请至少选择一个PACS节点"
→ 停留在当前面板
场景3: 加载 PACS 节点失败
进入面板时加载失败
→ 显示错误提示框
→ 提供"重试"按钮
→ 用户可以重新加载
场景4: 发送部分失败
发送过程中部分失败
→ 继续发送其他任务
→ 统计成功和失败数量
→ 最后显示汇总结果
→ 失败详情记录在控制台
场景5: 发送全部失败
所有发送任务都失败
→ 显示警告消息
→ 提示用户检查网络或节点配置
/**
* 从 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 handleSelectAllViewers = () => {
dispatch(selectAllViewers());
};
// 取消选中
const handleDeselectAllViewers = () => {
dispatch(deselectAllViewers());
};
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);
},
});
};
┌─────────────────────────────────────────┐
│ 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) │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 正在发送: 5 / 12 │
│ ████████████░░░░░░░░░░░░ 42% │
│ 成功: 4 失败: 1 │
└─────────────────────────────────────────┘
┌─────────────────────────────────────┐
│ 确认发送图像 │
├─────────────────────────────────────┤
│ │
│ 即将发送 3 个图像到以下 PACS 节点: │
│ │
│ PACS_Main, PACS_Backup │
│ │
│ 共 6 个发送任务 │
│ │
├─────────────────────────────────────┤
│ [取消] [确认发送] │
└─────────────────────────────────────┘
面板切换
PACS 节点加载
**Viewer 选