本文档描述了发送图像功能的完整实现方案。该功能允许用户将选中的 Study 发送到一个或多个 PACS 节点。
用户选择Study(在history表格中)
↓
打开发送面板(SendPanel)
↓
系统自动加载并显示PACS节点列表
↓
系统自动选中默认节点
↓
用户选择目标PACS节点(可多选)
↓
点击"发送图像"按钮
↓
显示确认对话框
↓
用户确认
↓
批量发送到所有选中的PACS节点
↓
显示发送进度
↓
发送完成,显示结果统计
↓
停留在发送面板(不自动返回)
src/pages/output/SendPanel.tsx
src/states/output/pacsNode/pacsNodeSlice.ts
src/states/patient/worklist/slices/history.ts
src/states/patient/worklist/slices/historyPanelSwitchSlice.ts
src/API/imageActions.ts
sendStudyToPacs(studyId: string, pacsName: string)
: 按 Study 批量发送图像src/API/output/pacsNodeActions.ts
getPacsNodeList()
: 获取 PACS 节点列表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; // 是否为默认节点
}
SendPanel 组件挂载
↓
useEffect 触发
↓
dispatch(fetchPacsNodesThunk())
↓
调用 getPacsNodeList() API
↓
返回 PacsNode[] 数据
↓
pacsNodeSlice 更新 state
↓
自动选中 is_default=true 的节点
↓
UI 更新,显示节点列表
用户点击"发送图像"按钮
↓
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)
↓
发送完成
↓
显示结果统计
↓
重置进度状态
// ✅ 正确方式:在事件处理器中使用 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()─────────────────────────────────────>│
│ │ │ │ │
│ │ │ │<─返回结果─────────┤
│ │ │ │ │
│ ├─更新进度状态 │ │ │
│ │ │ │ │
│ ├─显示完成消息 │ │ │
│ │ │ │ │
├─查看结果───────>│ │ │ │
│ (停留在面板) │ │ │ │
const [sendingProgress, setSendingProgress] = useState({
isSending: false, // 是否正在发送
total: 0, // 总任务数
completed: 0, // 已完成数
failed: 0, // 失败数
});
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 = 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);
},
});
};
{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>
)}
问题: 发送进度是否需要放在 Redux 中?
决策: 使用组件内部 useState
管理
理由:
问题: 如何在事件处理器中获取 Redux 状态?
决策: 使用 store.getState()
而不是 useAppSelector
理由:
useAppSelector
是 Hook,只能在组件顶层使用store.getState()
问题: 单个发送任务失败时如何处理?
决策: 记录失败但继续发送其他任务
理由:
问题: 串行还是并行发送?
决策: 串行发送(一个接一个)
理由:
问题: 发送完成后是否自动返回?
决策: 停留在发送面板
理由:
PACS 节点管理
节点选择
发送功能
用户体验
性能优化
功能增强
用户体验
数据源
historySelection
获取 Study IDsworkSelection
和 historySelection
Hook 使用
useAppSelector
store.getState()
错误处理
用户提示
src/pages/output/SendPanel.tsx
- 发送面板组件src/states/output/pacsNode/pacsNodeSlice.ts
- PACS 节点状态管理src/states/patient/worklist/slices/history.ts
- History 列表状态管理src/API/imageActions.ts
- 图像发送 APIsrc/API/output/pacsNodeActions.ts
- PACS 节点 API