|
@@ -0,0 +1,432 @@
|
|
|
|
+# 传输队列状态管理实现文档
|
|
|
|
+
|
|
|
|
+## 概述
|
|
|
|
+
|
|
|
|
+本文档描述了传输队列(Send Job Queue)功能的状态管理实现,包括 Redux 状态设计、API 集成和组件使用方法。
|
|
|
|
+
|
|
|
|
+## 📁 文件结构
|
|
|
|
+
|
|
|
|
+```
|
|
|
|
+src/
|
|
|
|
+├── domain/output/
|
|
|
|
+│ └── sendJob.ts # 领域模型:发送任务数据类型
|
|
|
|
+├── API/output/
|
|
|
|
+│ ├── sendJobActions.ts # API 层:传输队列接口(导入领域模型)
|
|
|
|
+│ └── pacsNodeActions.ts # API 层:PACS 节点接口
|
|
|
|
+├── states/output/sendJob/
|
|
|
|
+│ ├── types/
|
|
|
|
+│ │ └── sendJobFilter.ts # 筛选条件类型(状态层特有)
|
|
|
|
+│ └── slices/
|
|
|
|
+│ └── sendJobSlice.ts # Redux 状态切片(导入领域模型)
|
|
|
|
+├── states/store.ts # Redux Store 配置
|
|
|
|
+└── pages/patient/
|
|
|
|
+ └── OutputList.tsx # 传输列表页面
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 架构说明
|
|
|
|
+
|
|
|
|
+本项目采用**领域驱动设计(DDD)**的分层架构:
|
|
|
|
+
|
|
|
|
+1. **领域层(Domain)**:定义核心业务实体,独立于技术实现
|
|
|
|
+2. **API 层**:负责与后端通信,使用领域模型
|
|
|
|
+3. **状态层(State)**:负责状态管理,使用领域模型
|
|
|
|
+4. **UI 层**:负责展示和交互
|
|
|
|
+
|
|
|
|
+## 🎯 核心功能
|
|
|
|
+
|
|
|
|
+### 1. 数据类型定义
|
|
|
|
+
|
|
|
|
+#### SendJob(发送任务)- 领域模型
|
|
|
|
+
|
|
|
|
+**定义位置**:`src/domain/output/sendJob.ts`
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+// 从领域层导入
|
|
|
|
+import { SendJob } from '@/domain/output/sendJob';
|
|
|
|
+
|
|
|
|
+interface SendJob {
|
|
|
|
+ task_id: string; // 任务唯一标识
|
|
|
|
+ patient_name: string; // 患者姓名
|
|
|
|
+ patient_id: string; // 患者 ID
|
|
|
|
+ priority: 'High' | 'Medium' | 'Low'; // 优先级
|
|
|
|
+ status: 'ARRIVED' | 'SENDING' | 'FAILED' | 'SUCCESS'; // 状态
|
|
|
|
+ retry_count?: number; // 重试次数
|
|
|
|
+ destination: string; // 目标 PACS 节点名称
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+> ⚠️ **重要**:SendJob 是领域模型,定义在 `src/domain/output/sendJob.ts`。API 层和状态层都应该从领域层导入此类型,确保类型定义的单一数据源。
|
|
|
|
+
|
|
|
|
+#### SendJobFilter(筛选条件)
|
|
|
|
+```typescript
|
|
|
|
+interface SendJobFilter {
|
|
|
|
+ start_time?: string; // 开始时间 (RFC3339Nano 格式)
|
|
|
|
+ end_time?: string; // 结束时间 (RFC3339Nano 格式)
|
|
|
|
+ page: number; // 当前页码
|
|
|
|
+ page_size: number; // 每页数量
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. Redux 状态结构
|
|
|
|
+
|
|
|
|
+状态由 5 个 slice 组成:
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+{
|
|
|
|
+ sendJobEntities: {
|
|
|
|
+ data: SendJob[]; // 任务列表数据
|
|
|
|
+ total: number; // 总数量
|
|
|
|
+ },
|
|
|
|
+ sendJobFilters: {
|
|
|
|
+ start_time?: string;
|
|
|
|
+ end_time?: string;
|
|
|
|
+ page: number;
|
|
|
|
+ page_size: number;
|
|
|
|
+ },
|
|
|
|
+ sendJobPagination: {
|
|
|
|
+ page: number;
|
|
|
|
+ pageSize: number;
|
|
|
|
+ },
|
|
|
|
+ sendJobSelection: {
|
|
|
|
+ selectedIds: string[]; // 选中的任务 ID 列表
|
|
|
|
+ },
|
|
|
|
+ sendJobUI: {
|
|
|
|
+ loading: boolean; // 加载状态
|
|
|
|
+ error?: string | null; // 错误信息
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 3. 异步操作(Thunks)
|
|
|
|
+
|
|
|
|
+#### fetchSendQueueThunk - 获取发送队列
|
|
|
|
+```typescript
|
|
|
|
+import { fetchSendQueueThunk } from '@/states/output/sendJob/slices/sendJobSlice';
|
|
|
|
+
|
|
|
|
+// 在组件中使用
|
|
|
|
+dispatch(fetchSendQueueThunk({
|
|
|
|
+ page: 1,
|
|
|
|
+ pageSize: 10,
|
|
|
|
+ filters: {
|
|
|
|
+ start_time: '2025-10-10T00:00:00.000+08:00',
|
|
|
|
+ end_time: '2025-10-10T23:59:59.999+08:00',
|
|
|
|
+ }
|
|
|
|
+}));
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+#### deleteSendJobThunk - 删除任务
|
|
|
|
+```typescript
|
|
|
|
+import { deleteSendJobThunk } from '@/states/output/sendJob/slices/sendJobSlice';
|
|
|
|
+
|
|
|
|
+// 删除单个或多个任务
|
|
|
|
+dispatch(deleteSendJobThunk(['task_id_1', 'task_id_2']));
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+#### retrySendJobThunk - 重试任务
|
|
|
|
+```typescript
|
|
|
|
+import { retrySendJobThunk } from '@/states/output/sendJob/slices/sendJobSlice';
|
|
|
|
+
|
|
|
|
+// 重试失败的任务
|
|
|
|
+dispatch(retrySendJobThunk({
|
|
|
|
+ taskId: 'task_id',
|
|
|
|
+ instanceUid: 'sop_instance_uid' // 需要图像实例 UID
|
|
|
|
+}));
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 💡 组件使用示例
|
|
|
|
+
|
|
|
|
+### 基础用法
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+import React, { useEffect } from 'react';
|
|
|
|
+import { useAppDispatch, useAppSelector } from '@/states/store';
|
|
|
|
+import {
|
|
|
|
+ fetchSendQueueThunk,
|
|
|
|
+ deleteSendJobThunk,
|
|
|
|
+ retrySendJobThunk,
|
|
|
|
+ sendJobActions,
|
|
|
|
+} from '@/states/output/sendJob/slices/sendJobSlice';
|
|
|
|
+
|
|
|
|
+const SendJobList: React.FC = () => {
|
|
|
|
+ const dispatch = useAppDispatch();
|
|
|
|
+
|
|
|
|
+ // 获取状态
|
|
|
|
+ const { data: sendJobs, total } = useAppSelector(state => state.sendJobEntities);
|
|
|
|
+ const { loading, error } = useAppSelector(state => state.sendJobUI);
|
|
|
|
+ const filters = useAppSelector(state => state.sendJobFilters);
|
|
|
|
+ const { page, pageSize } = useAppSelector(state => state.sendJobPagination);
|
|
|
|
+ const { selectedIds } = useAppSelector(state => state.sendJobSelection);
|
|
|
|
+
|
|
|
|
+ // 初始加载数据
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ dispatch(fetchSendQueueThunk({ page, pageSize, filters }));
|
|
|
|
+ }, [dispatch, page, pageSize, filters]);
|
|
|
|
+
|
|
|
|
+ // 刷新数据
|
|
|
|
+ const handleRefresh = () => {
|
|
|
|
+ dispatch(fetchSendQueueThunk({ page, pageSize, filters }));
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ // 删除选中的任务
|
|
|
|
+ const handleDelete = () => {
|
|
|
|
+ if (selectedIds.length > 0) {
|
|
|
|
+ dispatch(deleteSendJobThunk(selectedIds));
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ // 重试任务
|
|
|
|
+ const handleRetry = (taskId: string, instanceUid: string) => {
|
|
|
|
+ dispatch(retrySendJobThunk({ taskId, instanceUid }));
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ // 设置时间范围筛选
|
|
|
|
+ const handleDateRangeChange = (startTime: string, endTime: string) => {
|
|
|
|
+ dispatch(sendJobActions.setStartTime(startTime));
|
|
|
|
+ dispatch(sendJobActions.setEndTime(endTime));
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ // 切换页码
|
|
|
|
+ const handlePageChange = (newPage: number) => {
|
|
|
|
+ dispatch(sendJobActions.setPage(newPage));
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ return (
|
|
|
|
+ <div>
|
|
|
|
+ {/* 渲染列表 */}
|
|
|
|
+ {loading && <div>加载中...</div>}
|
|
|
|
+ {error && <div>错误: {error}</div>}
|
|
|
|
+
|
|
|
|
+ <ul>
|
|
|
|
+ {sendJobs.map(job => (
|
|
|
|
+ <li key={job.task_id}>
|
|
|
|
+ {job.patient_name} - {job.status}
|
|
|
|
+ {job.status === 'FAILED' && (
|
|
|
|
+ <button onClick={() => handleRetry(job.task_id, 'instance_uid')}>
|
|
|
|
+ 重试
|
|
|
|
+ </button>
|
|
|
|
+ )}
|
|
|
|
+ </li>
|
|
|
|
+ ))}
|
|
|
|
+ </ul>
|
|
|
|
+
|
|
|
|
+ {/* 分页 */}
|
|
|
|
+ <div>
|
|
|
|
+ <button onClick={() => handlePageChange(page - 1)} disabled={page === 1}>
|
|
|
|
+ 上一页
|
|
|
|
+ </button>
|
|
|
|
+ <span>第 {page} 页,共 {Math.ceil(total / pageSize)} 页</span>
|
|
|
|
+ <button
|
|
|
|
+ onClick={() => handlePageChange(page + 1)}
|
|
|
|
+ disabled={page >= Math.ceil(total / pageSize)}
|
|
|
|
+ >
|
|
|
|
+ 下一页
|
|
|
|
+ </button>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ {/* 操作按钮 */}
|
|
|
|
+ <button onClick={handleRefresh}>刷新</button>
|
|
|
|
+ <button onClick={handleDelete} disabled={selectedIds.length === 0}>
|
|
|
|
+ 删除选中
|
|
|
|
+ </button>
|
|
|
|
+ </div>
|
|
|
|
+ );
|
|
|
|
+};
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 🔧 高级用法
|
|
|
|
+
|
|
|
|
+### 1. 选择管理
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+import { sendJobSelectionSlice } from '@/states/output/sendJob/slices/sendJobSlice';
|
|
|
|
+
|
|
|
|
+// 选中任务
|
|
|
|
+dispatch(sendJobSelectionSlice.actions.setSelectedIds(['task_id_1', 'task_id_2']));
|
|
|
|
+
|
|
|
|
+// 清空选择
|
|
|
|
+dispatch(sendJobSelectionSlice.actions.clearSelection());
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 分页控制
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+import { sendJobPaginationSlice } from '@/states/output/sendJob/slices/sendJobSlice';
|
|
|
|
+
|
|
|
|
+// 设置页码
|
|
|
|
+dispatch(sendJobPaginationSlice.actions.setPage(2));
|
|
|
|
+
|
|
|
|
+// 设置每页数量
|
|
|
|
+dispatch(sendJobPaginationSlice.actions.setPageSize(20));
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 3. 筛选条件
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+import { sendJobFiltersSlice } from '@/states/output/sendJob/slices/sendJobSlice';
|
|
|
|
+
|
|
|
|
+// 设置完整筛选条件
|
|
|
|
+dispatch(sendJobFiltersSlice.actions.setFilters({
|
|
|
|
+ start_time: '2025-10-10T00:00:00.000+08:00',
|
|
|
|
+ end_time: '2025-10-10T23:59:59.999+08:00',
|
|
|
|
+ page: 1,
|
|
|
|
+ page_size: 10,
|
|
|
|
+}));
|
|
|
|
+
|
|
|
|
+// 重置筛选条件
|
|
|
|
+dispatch(sendJobFiltersSlice.actions.resetFilters());
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 📊 状态监听
|
|
|
|
+
|
|
|
|
+### 监听任务状态变化
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+useEffect(() => {
|
|
|
|
+ // 当任务数据变化时执行操作
|
|
|
|
+ console.log('当前任务列表:', sendJobs);
|
|
|
|
+ console.log('总数:', total);
|
|
|
|
+}, [sendJobs, total]);
|
|
|
|
+
|
|
|
|
+useEffect(() => {
|
|
|
|
+ // 当加载状态变化时
|
|
|
|
+ if (loading) {
|
|
|
|
+ console.log('正在加载...');
|
|
|
|
+ }
|
|
|
|
+}, [loading]);
|
|
|
|
+
|
|
|
|
+useEffect(() => {
|
|
|
|
+ // 当发生错误时
|
|
|
|
+ if (error) {
|
|
|
|
+ console.error('发生错误:', error);
|
|
|
|
+ // 可以显示错误提示
|
|
|
|
+ }
|
|
|
|
+}, [error]);
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 🔄 与其他功能集成
|
|
|
|
+
|
|
|
|
+### 1. 图像发送成功后自动刷新
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+// 在图像发送成功的处理中
|
|
|
|
+const handleImageSendSuccess = () => {
|
|
|
|
+ // 刷新传输队列
|
|
|
|
+ dispatch(fetchSendQueueThunk({ page, pageSize, filters }));
|
|
|
|
+};
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. MQTT 消息监听
|
|
|
|
+
|
|
|
|
+如果需要实时更新任务状态,可以监听 MQTT 消息:
|
|
|
|
+
|
|
|
|
+```typescript
|
|
|
|
+useEffect(() => {
|
|
|
|
+ // 订阅 MQTT 消息
|
|
|
|
+ const handleMqttMessage = (message: any) => {
|
|
|
|
+ if (message.topic === 'send_job_status_update') {
|
|
|
|
+ // 刷新列表
|
|
|
|
+ dispatch(fetchSendQueueThunk({ page, pageSize, filters }));
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ // 注册监听器
|
|
|
|
+ mqttService.subscribe('send_job_status_update', handleMqttMessage);
|
|
|
|
+
|
|
|
|
+ return () => {
|
|
|
|
+ // 清理监听器
|
|
|
|
+ mqttService.unsubscribe('send_job_status_update', handleMqttMessage);
|
|
|
|
+ };
|
|
|
|
+}, [dispatch, page, pageSize, filters]);
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## ⚠️ 注意事项
|
|
|
|
+
|
|
|
|
+### 1. 前端分页 vs 后端分页
|
|
|
|
+
|
|
|
|
+当前实现在**前端进行分页**:
|
|
|
|
+```typescript
|
|
|
|
+// 在 fetchSendQueueThunk 中
|
|
|
|
+const startIndex = (page - 1) * pageSize;
|
|
|
|
+const endIndex = startIndex + pageSize;
|
|
|
|
+const paginatedData = response.data.slice(startIndex, endIndex);
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+如果后端支持分页,应该修改为:
|
|
|
|
+```typescript
|
|
|
|
+const response = await getSendQueue({
|
|
|
|
+ start_time: filters.start_time,
|
|
|
|
+ end_time: filters.end_time,
|
|
|
|
+ page, // 传递页码
|
|
|
|
+ page_size: pageSize, // 传递每页数量
|
|
|
|
+});
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 重试操作需要 instance_uid
|
|
|
|
+
|
|
|
|
+重试任务时需要提供图像实例 UID,这个信息可能需要从:
|
|
|
|
+- 任务详情中获取
|
|
|
|
+- 与 Study/Series/Image 数据关联
|
|
|
|
+- 后端 API 返回任务时包含
|
|
|
|
+
|
|
|
|
+### 3. 时间格式
|
|
|
|
+
|
|
|
|
+时间筛选使用 RFC3339Nano 格式:
|
|
|
|
+```typescript
|
|
|
|
+// 正确格式
|
|
|
|
+'2025-10-10T00:00:00.000+08:00'
|
|
|
|
+'2025-10-10T23:59:59.999+08:00'
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+## 🐛 调试技巧
|
|
|
|
+
|
|
|
|
+### 1. 查看 Redux 状态
|
|
|
|
+
|
|
|
|
+使用 Redux DevTools:
|
|
|
|
+```typescript
|
|
|
|
+// 在浏览器中查看
|
|
|
|
+window.__REDUX_DEVTOOLS_EXTENSION__
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 2. 控制台日志
|
|
|
|
+
|
|
|
|
+Thunks 中已包含日志:
|
|
|
|
+```typescript
|
|
|
|
+console.log('重试发送任务,task_id:', taskId);
|
|
|
|
+console.log('重试发送任务 fulfilled,result:', result);
|
|
|
|
+```
|
|
|
|
+
|
|
|
|
+### 3. 网络请求
|
|
|
|
+
|
|
|
|
+检查浏览器 Network 标签页,查看 API 请求和响应。
|
|
|
|
+
|
|
|
|
+## 📝 待完成事项
|
|
|
|
+
|
|
|
|
+1. **WorklistTable 组件适配**
|
|
|
|
+ - 当前 WorklistTable 组件需要调整以支持传输队列数据
|
|
|
|
+ - 需要创建专门的 SendJobTable 组件,或修改 WorklistTable 支持多种数据类型
|
|
|
|
+
|
|
|
|
+2. **OutputOperationPanel 组件集成**
|
|
|
|
+ - 添加重试按钮
|
|
|
|
+ - 添加删除按钮
|
|
|
|
+ - 添加时间范围筛选器
|
|
|
|
+
|
|
|
|
+3. **实时状态更新**
|
|
|
|
+ - 集成 MQTT 监听传输任务状态变化
|
|
|
|
+ - 自动刷新列表
|
|
|
|
+
|
|
|
|
+4. **错误处理优化**
|
|
|
|
+ - 显示友好的错误提示
|
|
|
|
+ - 添加重试机制
|
|
|
|
+
|
|
|
|
+## 📚 相关文档
|
|
|
|
+
|
|
|
|
+- [DR.md 第 15 节 - 传输队列接口定义](../DR.md#15-传输队列)
|
|
|
|
+- [API 实现文档](../../src/API/output/sendJobActions.ts)
|
|
|
|
+- [Redux Toolkit 官方文档](https://redux-toolkit.js.org/)
|
|
|
|
+
|
|
|
|
+## 🎓 学习资源
|
|
|
|
+
|
|
|
|
+- Redux 状态管理模式
|
|
|
|
+- TypeScript 类型系统
|
|
|
|
+- React Hooks 最佳实践
|
|
|
|
+- 分页和筛选实现模式
|