Przeglądaj źródła

feat: 初步实现传输队列页面,获取和显示发送任务

- 创建传输队列领域模型 src/domain/output/sendJob.ts
- 实现传输队列 API 接口(获取队列、删除任务、重试任务)
- 实现传输队列 Redux 状态管理(5个slice和3个thunk)
- 创建通用表格组件 GenericDataTable 支持泛型数据类型
- 在 OutputList 页面中应用通用表格组件展示传输队列
- 集成分页、选择、加载状态等功能
- 添加完整的使用文档

改动文件:
- src/domain/output/sendJob.ts (新增)
- src/API/output/sendJobActions.ts (修改)
- src/states/output/sendJob/types/sendJobFilter.ts (新增)
- src/states/output/sendJob/slices/sendJobSlice.ts (新增)
- src/states/store.ts (修改)
- src/components/GenericDataTable.tsx (新增)
- src/pages/patient/OutputList.tsx (修改)
- docs/实现/传输队列状态管理实现.md (新增)
- docs/实现/通用表格组件GenericDataTable.md (新增)
sw 3 dni temu
rodzic
commit
0bac4187cf

+ 432 - 0
docs/实现/传输队列状态管理实现.md

@@ -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 最佳实践
+- 分页和筛选实现模式

+ 433 - 0
docs/实现/通用表格组件GenericDataTable.md

@@ -0,0 +1,433 @@
+# 通用表格组件 GenericDataTable 使用文档
+
+## 概述
+
+`GenericDataTable` 是一个基于 Ant Design Table 的通用数据表格组件,支持泛型数据类型、列配置、列宽调整、行交互等功能。
+
+## 📁 文件位置
+
+- 组件:`src/components/GenericDataTable.tsx`
+- 使用示例:
+  - `src/pages/patient/OutputList.tsx` - 传输队列表格
+  - `src/pages/patient/worklist.tsx` - 工作列表表格(待改造)
+
+## 🎯 核心特性
+
+### 1. 泛型支持
+- 支持任意数据类型
+- TypeScript 类型安全
+- 自动推断列的数据类型
+
+### 2. 列配置系统
+- 显示/隐藏列
+- 列宽调整
+- 列排序
+- 与 `columnConfigService` 集成
+
+### 3. 列宽调整
+- 支持鼠标拖拽调整列宽
+- 最小宽度 50px
+- 使用 react-resizable
+
+### 4. 行交互
+- 单击选择
+- 双击操作
+- 触摸屏双击支持
+- 行高亮显示
+
+### 5. 其他特性
+- 加载状态
+- 自定义渲染
+- 响应式布局
+
+## 💡 使用示例
+
+### 基础用法
+
+```typescript
+import { GenericDataTable, ColumnDefinition } from '@/components/GenericDataTable';
+
+interface MyData {
+  id: string;
+  name: string;
+  status: string;
+}
+
+const columns: ColumnDefinition<MyData>[] = [
+  {
+    title: '名称',
+    dataIndex: 'name',
+    width: 150,
+  },
+  {
+    title: '状态',
+    dataIndex: 'status',
+    width: 100,
+  },
+];
+
+function MyTable() {
+  const [selectedIds, setSelectedIds] = useState<string[]>([]);
+  const data: MyData[] = [
+    { id: '1', name: '张三', status: '正常' },
+    { id: '2', name: '李四', status: '异常' },
+  ];
+
+  return (
+    <GenericDataTable<MyData>
+      dataSource={data}
+      rowKey="id"
+      columnDefinitions={columns}
+      selectedIds={selectedIds}
+      onRowClick={(record) => setSelectedIds([record.id])}
+    />
+  );
+}
+```
+
+### 带国际化的列定义
+
+```typescript
+import { FormattedMessage } from 'react-intl';
+
+const columns: ColumnDefinition<SendJob>[] = [
+  {
+    title: (
+      <FormattedMessage
+        id="outputTable.name"
+        defaultMessage="Patient Name"
+      />
+    ),
+    dataIndex: 'patient_name',
+    width: 150,
+  },
+  {
+    title: (
+      <FormattedMessage
+        id="outputTable.status"
+        defaultMessage="Status"
+      />
+    ),
+    dataIndex: 'status',
+    width: 120,
+  },
+];
+```
+
+### 自定义渲染
+
+```typescript
+const columns: ColumnDefinition<SendJob>[] = [
+  {
+    title: '重试次数',
+    dataIndex: 'retry_count',
+    width: 100,
+    render: (value) => value ?? 0, // 显示 0 而不是 undefined
+  },
+  {
+    title: '状态',
+    dataIndex: 'status',
+    width: 100,
+    render: (value) => {
+      const statusMap = {
+        'ARRIVED': '已到达',
+        'SENDING': '发送中',
+        'FAILED': '失败',
+        'SUCCESS': '成功',
+      };
+      return statusMap[value] || value;
+    },
+  },
+];
+```
+
+### 集成列配置系统
+
+```typescript
+import { columnConfigService } from '@/config/tableColumns';
+import { ColumnConfig } from '@/config/tableColumns/types/columnConfig';
+
+function MyTable() {
+  const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>([]);
+
+  // 加载列配置
+  useEffect(() => {
+    columnConfigService
+      .getColumnConfig('output')
+      .then((config) => setColumnConfig(config.columns))
+      .catch((error) => {
+        console.error('Failed to load column config:', error);
+        setColumnConfig([]);
+      });
+  }, []);
+
+  return (
+    <GenericDataTable<SendJob>
+      dataSource={data}
+      rowKey="task_id"
+      columnDefinitions={columns}
+      columnConfig={columnConfig} // 传入列配置
+      selectedIds={selectedIds}
+      onRowClick={handleRowClick}
+    />
+  );
+}
+```
+
+### 完整示例:传输队列表格
+
+```typescript
+import React, { useState, useEffect } from 'react';
+import { GenericDataTable, ColumnDefinition } from '@/components/GenericDataTable';
+import GenericPagination from '@/components/GenericPagination';
+import { useAppDispatch, useAppSelector } from '@/states/store';
+import {
+  fetchSendQueueThunk,
+  sendJobSelectionSlice,
+  sendJobPaginationSlice,
+} from '@/states/output/sendJob/slices/sendJobSlice';
+import { SendJob } from '@/domain/output/sendJob';
+import { FormattedMessage } from 'react-intl';
+
+// 定义列
+const sendJobColumns: ColumnDefinition<SendJob>[] = [
+  {
+    title: <FormattedMessage id="outputTable.name" defaultMessage="Patient Name" />,
+    dataIndex: 'patient_name',
+    width: 150,
+  },
+  {
+    title: <FormattedMessage id="outputTable.status" defaultMessage="Status" />,
+    dataIndex: 'status',
+    width: 120,
+  },
+];
+
+function OutputList() {
+  const dispatch = useAppDispatch();
+  const { data: sendJobs } = useAppSelector((state) => state.sendJobEntities);
+  const { loading } = useAppSelector((state) => state.sendJobUI);
+  const { page, pageSize } = useAppSelector((state) => state.sendJobPagination);
+  const selectedIds = useAppSelector((state) => state.sendJobSelection.selectedIds);
+
+  // 加载数据
+  useEffect(() => {
+    dispatch(fetchSendQueueThunk({ page, pageSize, filters: {} }));
+  }, [dispatch, page, pageSize]);
+
+  // 行点击
+  const handleRowClick = (record: SendJob) => {
+    dispatch(sendJobSelectionSlice.actions.setSelectedIds([record.task_id]));
+  };
+
+  return (
+    <div className="h-full flex flex-col">
+      <div className="flex-1 overflow-auto">
+        <GenericDataTable<SendJob>
+          dataSource={sendJobs}
+          rowKey="task_id"
+          columnDefinitions={sendJobColumns}
+          selectedIds={selectedIds}
+          onRowClick={handleRowClick}
+          loading={loading}
+          className="px-4"
+        />
+      </div>
+      <GenericPagination
+        paginationSelector={(state) => state.sendJobPagination}
+        entitiesSelector={(state) => state.sendJobEntities}
+        paginationActions={sendJobPaginationSlice.actions}
+        className="border-t"
+      />
+    </div>
+  );
+}
+```
+
+## 📝 API 参考
+
+### GenericDataTableProps
+
+| 属性 | 类型 | 必填 | 默认值 | 描述 |
+|------|------|------|--------|------|
+| dataSource | T[] | ✅ | - | 数据源 |
+| rowKey | keyof T \| ((record: T) => string) | ✅ | - | 行键,用于唯一标识每一行 |
+| columnDefinitions | ColumnDefinition<T>[] | ✅ | - | 列定义数组 |
+| columnConfig | ColumnConfig[] | ❌ | [] | 列配置(显示/隐藏、排序等) |
+| selectedIds | string[] | ✅ | - | 选中的行 ID 列表 |
+| onRowClick | (record: T) => void | ❌ | - | 行点击事件 |
+| onRowDoubleClick | (record: T) => void | ❌ | - | 行双击事件 |
+| className | string | ❌ | '' | 自定义类名 |
+| loading | boolean | ❌ | false | 加载状态 |
+
+### ColumnDefinition
+
+| 属性 | 类型 | 必填 | 默认值 | 描述 |
+|------|------|------|--------|------|
+| title | React.ReactNode | ✅ | - | 列标题 |
+| dataIndex | keyof T | ✅ | - | 数据索引 |
+| width | number | ❌ | 150 | 默认宽度(px) |
+| render | (value: any, record: T, index: number) => React.ReactNode | ❌ | - | 自定义渲染函数 |
+
+## 🔧 高级用法
+
+### 1. 动态行键
+
+```typescript
+<GenericDataTable<MyData>
+  dataSource={data}
+  rowKey={(record) => `${record.type}_${record.id}`} // 组合键
+  // ...
+/>
+```
+
+### 2. 条件渲染
+
+```typescript
+const columns: ColumnDefinition<SendJob>[] = [
+  {
+    title: '状态',
+    dataIndex: 'status',
+    render: (value, record) => {
+      if (value === 'FAILED') {
+        return <span className="text-red-500">失败</span>;
+      }
+      if (value === 'SUCCESS') {
+        return <span className="text-green-500">成功</span>;
+      }
+      return value;
+    },
+  },
+];
+```
+
+### 3. 多选支持
+
+```typescript
+const [selectedIds, setSelectedIds] = useState<string[]>([]);
+
+const handleRowClick = (record: MyData) => {
+  setSelectedIds((prev) => {
+    if (prev.includes(record.id)) {
+      // 取消选择
+      return prev.filter((id) => id !== record.id);
+    } else {
+      // 添加选择
+      return [...prev, record.id];
+    }
+  });
+};
+
+<GenericDataTable<MyData>
+  // ...
+  selectedIds={selectedIds}
+  onRowClick={handleRowClick}
+/>
+```
+
+## ⚠️ 注意事项
+
+### 1. 泛型类型约束
+
+数据类型 `T` 必须是对象类型:
+```typescript
+T extends Record<string, any>
+```
+
+### 2. rowKey 必须返回唯一值
+
+```typescript
+// ✅ 正确
+rowKey="id"  // 确保 id 唯一
+
+// ✅ 正确
+rowKey={(record) => record.task_id}
+
+// ❌ 错误 - 可能重复
+rowKey={(record) => record.status}
+```
+
+### 3. 列配置优先级
+
+如果提供了 `columnConfig`,则:
+1. 只显示 `visible: true` 的列
+2. 按 `order` 排序
+3. 使用 `width` 覆盖默认宽度
+
+如果没有 `columnConfig`,显示所有列。
+
+### 4. 性能优化
+
+对于大数据集,考虑:
+- 使用虚拟滚动
+- 后端分页
+- 懒加载
+
+## 🎨 样式定制
+
+### 1. 自定义类名
+
+```typescript
+<GenericDataTable
+  className="my-custom-table"
+  // ...
+/>
+```
+
+### 2. 选中行样式
+
+当前使用 `bg-yellow-500` 和 `hover:bg-yellow-800`,可以通过修改组件源码自定义:
+
+```typescript
+// 在 GenericDataTable.tsx 中
+rowClassName={(record) => {
+  const rowId = getRowKey(record);
+  return selectedIds.includes(rowId)
+    ? 'bg-blue-500 hover:bg-blue-700' // 自定义颜色
+    : '';
+}}
+```
+
+## 🔄 与现有组件对比
+
+### WorklistTable vs GenericDataTable
+
+| 特性 | WorklistTable | GenericDataTable |
+|------|--------------|------------------|
+| 数据类型 | 固定 Task | 泛型 T |
+| 列定义 | 硬编码 | 传入参数 |
+| 复用性 | 低 | 高 |
+| 类型安全 | Task 类型 | 完全类型安全 |
+| 列配置 | 支持 | 支持 |
+| 列宽调整 | 支持 | 支持 |
+| 行交互 | 支持 | 支持 |
+
+## 📚 相关文档
+
+- [传输队列状态管理](./传输队列状态管理实现.md)
+- [表格列配置功能](./表格列配置功能.md)
+- [Ant Design Table](https://ant.design/components/table-cn/)
+- [react-resizable](https://github.com/react-grid-layout/react-resizable)
+
+## 🚀 未来改进
+
+1. **虚拟滚动** - 支持大数据集
+2. **列拖拽排序** - 用户自定义列顺序
+3. **列固定** - 支持左右固定列
+4. **行展开** - 支持嵌套数据
+5. **导出功能** - CSV/Excel 导出
+6. **搜索过滤** - 列内搜索
+
+## 🐛 已知问题
+
+暂无
+
+## 📝 更新日志
+
+### v1.0.0 (2025-10-11)
+- ✨ 初始版本
+- ✅ 泛型支持
+- ✅ 列配置系统
+- ✅ 列宽调整
+- ✅ 行交互
+- ✅ 在 OutputList 中应用

+ 5 - 18
src/API/output/sendJobActions.ts

@@ -1,5 +1,6 @@
 import axiosInstance from '../interceptor';
 import type { PacsNode, PacsNodeListData } from './pacsNodeActions';
+import { SendJob } from '@/domain/output/sendJob';
 
 /**
  * Echo 测试请求参数
@@ -46,24 +47,10 @@ export interface EchoTestResponse {
 }
 
 /**
- * 发送队列项
+ * 发送队列项(类型别名,用于向后兼容)
+ * @deprecated 请使用 SendJob 类型
  */
-export interface SendQueueItem {
-  /** 任务 ID */
-  task_id: string;
-  /** 患者姓名 */
-  patient_name: string;
-  /** 患者 ID */
-  patient_id: string;
-  /** 优先级 */
-  priority: 'High' | 'Medium' | 'Low';
-  /** 状态 */
-  status: 'ARRIVED' | 'SENDING' | 'FAILED' | 'SUCCESS';
-  /** 重试次数 */
-  retry_count?: number;
-  /** 目标 PACS 节点 */
-  destination: string;
-}
+export type SendQueueItem = SendJob;
 
 /**
  * 发送队列响应
@@ -76,7 +63,7 @@ export interface SendQueueResponse {
   /** 解决方案 */
   solution: string;
   /** 发送队列列表 */
-  data: SendQueueItem[];
+  data: SendJob[];
 }
 
 /**

+ 233 - 0
src/components/GenericDataTable.tsx

@@ -0,0 +1,233 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { Table, TableColumnsType } from 'antd';
+import type { ResizeCallbackData } from 'react-resizable';
+import { Resizable } from 'react-resizable';
+import { useTouchDoubleClick } from '@/hooks/useTouchDoubleClick';
+import { ColumnConfig } from '@/config/tableColumns/types/columnConfig';
+
+interface TitlePropsType {
+  width: number;
+  onResize: (
+    e: React.SyntheticEvent<Element>,
+    data: ResizeCallbackData
+  ) => void;
+}
+
+const ResizableTitle: React.FC<
+  Readonly<
+    React.HTMLAttributes<HTMLTableCellElement | Resizable> & TitlePropsType
+  >
+> = (props) => {
+  const { onResize, width, ...restProps } = props;
+
+  if (!width) {
+    return <th {...restProps} />;
+  }
+
+  return (
+    <Resizable
+      width={width}
+      height={0}
+      minConstraints={[50, 0]}
+      handle={
+        <span
+          style={{
+            position: 'absolute',
+            right: '-5px',
+            bottom: '0',
+            zIndex: 1,
+            width: '10px',
+            height: '100%',
+            cursor: 'col-resize',
+          }}
+          onClick={(e) => e.stopPropagation()}
+        />
+      }
+      onResize={onResize}
+      draggableOpts={{ enableUserSelectHack: false }}
+    >
+      <th {...restProps} />
+    </Resizable>
+  );
+};
+
+/**
+ * 列定义接口
+ */
+export interface ColumnDefinition<T> {
+  /** 列标题 */
+  title: React.ReactNode;
+  /** 数据索引 */
+  dataIndex: keyof T;
+  /** 默认宽度 */
+  width?: number;
+  /** 自定义渲染 */
+  render?: (value: any, record: T, index: number) => React.ReactNode;
+}
+
+/**
+ * 通用数据表格组件属性
+ */
+export interface GenericDataTableProps<T> {
+  /** 数据源 */
+  dataSource: T[];
+  /** 行键 */
+  rowKey: keyof T | ((record: T) => string);
+  /** 列定义 */
+  columnDefinitions: ColumnDefinition<T>[];
+  /** 列配置(可选,用于显示/隐藏、排序等) */
+  columnConfig?: ColumnConfig[];
+  /** 选中的行 ID 列表 */
+  selectedIds: string[];
+  /** 行点击事件 */
+  onRowClick?: (record: T) => void;
+  /** 行双击事件 */
+  onRowDoubleClick?: (record: T) => void;
+  /** 自定义类名 */
+  className?: string;
+  /** 加载状态 */
+  loading?: boolean;
+}
+
+/**
+ * 通用数据表格组件
+ * 
+ * 支持泛型数据类型、列配置、列宽调整、行交互等功能
+ * 
+ * @example
+ * ```tsx
+ * <GenericDataTable
+ *   dataSource={data}
+ *   rowKey="id"
+ *   columnDefinitions={[
+ *     { title: '名称', dataIndex: 'name', width: 150 },
+ *     { title: '状态', dataIndex: 'status', width: 100 }
+ *   ]}
+ *   selectedIds={selectedIds}
+ *   onRowClick={handleRowClick}
+ *   onRowDoubleClick={handleRowDoubleClick}
+ * />
+ * ```
+ */
+export function GenericDataTable<T extends Record<string, any>>({
+  dataSource,
+  rowKey,
+  columnDefinitions,
+  columnConfig = [],
+  selectedIds,
+  onRowClick,
+  onRowDoubleClick,
+  className = '',
+  loading = false,
+}: GenericDataTableProps<T>) {
+  // 根据列配置过滤和排序列
+  const visibleColumns = useMemo(() => {
+    // 如果没有配置,显示所有列
+    if (columnConfig.length === 0) {
+      return columnDefinitions.map((col) => ({
+        ...col,
+        width: col.width ?? 150,
+      }));
+    }
+
+    // 根据配置过滤出可见的列
+    return columnDefinitions
+      .filter((col) => {
+        const config = columnConfig.find((c) => c.key === String(col.dataIndex));
+        return config?.visible ?? false;
+      })
+      .map((col) => {
+        const config = columnConfig.find((c) => c.key === String(col.dataIndex));
+        return {
+          ...col,
+          width: config?.width ?? col.width ?? 150,
+        };
+      })
+      .sort((a, b) => {
+        const orderA =
+          columnConfig.find((c) => c.key === String(a.dataIndex))?.order ?? 999;
+        const orderB =
+          columnConfig.find((c) => c.key === String(b.dataIndex))?.order ?? 999;
+        return orderA - orderB;
+      });
+  }, [columnDefinitions, columnConfig]);
+
+  // 列可调整大小的状态
+  const [columns, setColumns] =
+    useState<TableColumnsType<T>>(visibleColumns as TableColumnsType<T>);
+
+  useEffect(() => {
+    setColumns(visibleColumns as TableColumnsType<T>);
+  }, [visibleColumns]);
+
+  const handleResize =
+    (index: number) =>
+    (_: React.SyntheticEvent<Element>, { size }: ResizeCallbackData) => {
+      console.log('Resizing column:', index, size);
+      const newColumns = [...columns];
+      newColumns[index] = {
+        ...newColumns[index],
+        width: size.width,
+      };
+      setColumns(newColumns);
+    };
+
+  const mergedColumns = columns.map<TableColumnsType<T>[number]>(
+    (col, index) => ({
+      ...col,
+      onHeaderCell: (column: TableColumnsType<T>[number]) => ({
+        width: column.width,
+        onResize: handleResize(index) as React.ReactEventHandler<HTMLElement>,
+        style: { whiteSpace: 'nowrap' },
+      }),
+      onCell: () => ({ style: { whiteSpace: 'nowrap' } }),
+    })
+  );
+
+  // 获取行键值
+  const getRowKey = (record: T): string => {
+    if (typeof rowKey === 'function') {
+      return rowKey(record);
+    }
+    return String(record[rowKey]);
+  };
+
+  return (
+    <Table<T>
+      bordered
+      className={className}
+      columns={mergedColumns}
+      scroll={{ x: 'max-content' }}
+      components={{ header: { cell: ResizableTitle } }}
+      dataSource={dataSource}
+      rowKey={getRowKey}
+      pagination={false}
+      loading={loading}
+      onRow={(record, index) => {
+        const rowId = getRowKey(record);
+        const { handleTouchStart } = useTouchDoubleClick({
+          onDoubleClick: () => onRowDoubleClick?.(record),
+          delay: 300,
+        });
+
+        return {
+          onClick: () => onRowClick?.(record),
+          onDoubleClick: () => onRowDoubleClick?.(record),
+          onTouchStart: handleTouchStart,
+          'data-testid': `row-${index}`,
+          style: {
+            cursor: 'pointer',
+            userSelect: 'none',
+          },
+        };
+      }}
+      rowHoverable={false}
+      rowClassName={(record) => {
+        const rowId = getRowKey(record);
+        return selectedIds.includes(rowId)
+          ? 'bg-yellow-500 hover:bg-yellow-800'
+          : '';
+      }}
+    />
+  );
+}

+ 37 - 0
src/domain/output/sendJob.ts

@@ -0,0 +1,37 @@
+/**
+ * 发送任务优先级
+ */
+export type SendJobPriority = 'High' | 'Medium' | 'Low';
+
+/**
+ * 发送任务状态
+ */
+export type SendJobStatus = 'ARRIVED' | 'SENDING' | 'FAILED' | 'SUCCESS';
+
+/**
+ * 发送任务(传输队列项)
+ * 
+ * 表示一个待发送到 PACS 节点的任务
+ */
+export interface SendJob {
+  /** 任务 ID - 用作唯一标识符 */
+  task_id: string;
+  
+  /** 患者姓名 */
+  patient_name: string;
+  
+  /** 患者 ID */
+  patient_id: string;
+  
+  /** 优先级 */
+  priority: SendJobPriority;
+  
+  /** 当前状态 */
+  status: SendJobStatus;
+  
+  /** 重试次数(可选) */
+  retry_count?: number;
+  
+  /** 目标 PACS 节点名称 */
+  destination: string;
+}

+ 153 - 12
src/pages/patient/OutputList.tsx

@@ -1,21 +1,142 @@
-import React, { useState } from 'react';
-import { Row, Col, Button, Drawer, Grid, Pagination } from 'antd';
+import React, { useState, useEffect } from 'react';
+import { Row, Col, Button, Drawer, Grid } from 'antd';
 import { SettingOutlined } from '@ant-design/icons';
 import { FormattedMessage } from 'react-intl';
-import WorklistTable from './components/WorklistTable';
+import { GenericDataTable, ColumnDefinition } from '@/components/GenericDataTable';
+import GenericPagination from '@/components/GenericPagination';
 import OutputOperationPanel from './components/OutputOperationPanel';
+import { useAppDispatch, useAppSelector } from '@/states/store';
+import {
+  fetchSendQueueThunk,
+  sendJobSelectionSlice,
+  sendJobPaginationSlice,
+} from '@/states/output/sendJob/slices/sendJobSlice';
+import { SendJob } from '@/domain/output/sendJob';
 
 const { useBreakpoint } = Grid;
 
+// 定义传输队列的列
+const sendJobColumns: ColumnDefinition<SendJob>[] = [
+  {
+    title: (
+      <FormattedMessage
+        id="outputTable.name"
+        defaultMessage="Patient Name"
+      />
+    ),
+    dataIndex: 'patient_name',
+    width: 150,
+  },
+  {
+    title: (
+      <FormattedMessage
+        id="outputTable.id"
+        defaultMessage="Patient ID"
+      />
+    ),
+    dataIndex: 'patient_id',
+    width: 150,
+  },
+  {
+    title: (
+      <FormattedMessage
+        id="outputTable.priority"
+        defaultMessage="Priority"
+      />
+    ),
+    dataIndex: 'priority',
+    width: 100,
+  },
+  {
+    title: (
+      <FormattedMessage
+        id="outputTable.status"
+        defaultMessage="Status"
+      />
+    ),
+    dataIndex: 'status',
+    width: 120,
+  },
+  {
+    title: (
+      <FormattedMessage
+        id="outputTable.retryCount"
+        defaultMessage="Retry Count"
+      />
+    ),
+    dataIndex: 'retry_count',
+    width: 100,
+    render: (value) => value ?? 0,
+  },
+  {
+    title: (
+      <FormattedMessage
+        id="outputTable.target"
+        defaultMessage="Destination"
+      />
+    ),
+    dataIndex: 'destination',
+    width: 150,
+  },
+];
+
 const OutputlistPage: React.FC = () => {
   const screens = useBreakpoint();
   const [drawerVisible, setDrawerVisible] = useState(false);
+  
+  // Redux 状态和 dispatch
+  const dispatch = useAppDispatch();
+  const { data: sendJobs } = useAppSelector((state) => state.sendJobEntities);
+  const { loading } = useAppSelector((state) => state.sendJobUI);
+  const filters = useAppSelector((state) => state.sendJobFilters);
+  const { page, pageSize } = useAppSelector((state) => state.sendJobPagination);
+  const selectedIds = useAppSelector((state) => state.sendJobSelection.selectedIds);
+  
+  // 组件挂载时拉取数据
+  useEffect(() => {
+    dispatch(
+      fetchSendQueueThunk({
+        page,
+        pageSize,
+        filters,
+      })
+    );
+  }, [dispatch, page, pageSize, filters]);
+
+  // 行点击处理
+  const handleRowClick = (record: SendJob) => {
+    console.log('Row clicked:', record);
+    dispatch(sendJobSelectionSlice.actions.setSelectedIds([record.task_id]));
+  };
+
+  // 行双击处理(如果需要)
+  const handleRowDoubleClick = (record: SendJob) => {
+    console.log('Row double-clicked:', record);
+    // 可以添加双击逻辑,如查看详情
+  };
 
   return (
-    <div className="p-4">
+    <div className="h-full">
       {screens.xs ? (
         <>
-          <WorklistTable />
+          <div className="flex-1 overflow-auto">
+            <GenericDataTable<SendJob>
+              dataSource={sendJobs}
+              rowKey="task_id"
+              columnDefinitions={sendJobColumns}
+              selectedIds={selectedIds}
+              onRowClick={handleRowClick}
+              onRowDoubleClick={handleRowDoubleClick}
+              loading={loading}
+              className="px-4"
+            />
+          </div>
+          <GenericPagination
+            paginationSelector={(state) => state.sendJobPagination}
+            entitiesSelector={(state) => state.sendJobEntities}
+            paginationActions={sendJobPaginationSlice.actions}
+            className="border-t"
+          />
           <Button
             type="primary"
             shape="circle"
@@ -27,7 +148,7 @@ const OutputlistPage: React.FC = () => {
             title={
               <FormattedMessage
                 id="worklist.operationPanel"
-                defaultMessage="worklist.operationPanel"
+                defaultMessage="Operation Panel"
               />
             }
             placement="left"
@@ -39,14 +160,34 @@ const OutputlistPage: React.FC = () => {
           </Drawer>
         </>
       ) : (
-        <Row gutter={16}>
-          <Col span={screens.lg ? 18 : screens.md ? 20 : 24}>
-            <WorklistTable />
-            <div className="flex justify-center mt-4">
-              <Pagination defaultCurrent={1} total={50} />
+        <Row className="h-full">
+          <Col
+            span={screens.lg ? 18 : screens.md ? 20 : 24}
+            className="h-full flex flex-col"
+          >
+            <div className="flex-1 overflow-auto">
+              <GenericDataTable<SendJob>
+                dataSource={sendJobs}
+                rowKey="task_id"
+                columnDefinitions={sendJobColumns}
+                selectedIds={selectedIds}
+                onRowClick={handleRowClick}
+                onRowDoubleClick={handleRowDoubleClick}
+                loading={loading}
+                className="px-4"
+              />
             </div>
+            <GenericPagination
+              paginationSelector={(state) => state.sendJobPagination}
+              entitiesSelector={(state) => state.sendJobEntities}
+              paginationActions={sendJobPaginationSlice.actions}
+              className="border-t"
+            />
           </Col>
-          <Col span={screens.lg ? 6 : screens.md ? 4 : 0}>
+          <Col
+            span={screens.lg ? 6 : screens.md ? 4 : 0}
+            className="h-full overflow-auto"
+          >
             <OutputOperationPanel />
           </Col>
         </Row>

+ 149 - 0
src/states/output/sendJob/slices/sendJobSlice.ts

@@ -0,0 +1,149 @@
+/* eslint-disable */
+import { createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
+import { createEntityListSlices } from '../../../list_template/createListSlices';
+import {
+  createFetchThunk,
+  createDeleteThunk,
+} from '../../../list_template/thunk.factory';
+import { SendJob } from '@/domain/output/sendJob';
+import { SendJobFilter } from '../types/sendJobFilter';
+import {
+  getSendQueue,
+  deleteSendTask,
+  retrySendTask,
+} from '../../../../API/output/sendJobActions';
+import store from '@/states/store';
+import { Draft } from '@reduxjs/toolkit';
+import { EntitiesState } from '../../../list_template/type.model';
+
+/**
+ * 获取发送队列的 Thunk
+ */
+export const fetchSendQueueThunk = createFetchThunk<SendJobFilter, SendJob>(
+  'sendJob',
+  async ({ page, pageSize, filters }) => {
+    const response = await getSendQueue({
+      start_time: filters.start_time,
+      end_time: filters.end_time,
+    });
+    
+    // API 返回的是完整列表,需要在前端进行分页
+    // 注意:如果后端支持分页,应该在 API 层传递 page 和 pageSize 参数
+    const startIndex = (page - 1) * pageSize;
+    const endIndex = startIndex + pageSize;
+    const paginatedData = response.data.slice(startIndex, endIndex);
+    
+    return { 
+      data: paginatedData, 
+      total: response.data.length 
+    };
+  }
+);
+
+/**
+ * 删除发送任务的 Thunk
+ */
+export const deleteSendJobThunk = createDeleteThunk(
+  'sendJob',
+  async (ids: string[]) => {
+    await deleteSendTask(ids);
+    store.dispatch(sendJobSelectionSlice.actions.clearSelection());
+  }
+);
+
+/**
+ * 重试发送任务的 Thunk
+ */
+export const retrySendJobThunk = createAsyncThunk(
+  'sendJob/retry',
+  async ({ taskId, instanceUid }: { taskId: string; instanceUid: string }) => {
+    console.log(`重试发送任务,task_id: ${taskId}, instance_uid: ${instanceUid}`);
+    const result = await retrySendTask(instanceUid);
+    return { taskId, result };
+  }
+);
+
+/**
+ * 创建重试操作的 handlers
+ */
+const createRetryHandlers = () => ({
+  pending: (state: Draft<EntitiesState<SendJob>>) => {
+    console.log('重试发送任务 pending');
+  },
+  fulfilled: (
+    state: Draft<EntitiesState<SendJob>>,
+    action: PayloadAction<{ taskId: string; result: any }>
+  ) => {
+    const { taskId, result } = action.payload;
+    console.log(`重试发送任务 fulfilled,task_id: ${taskId}, result:`, result);
+    
+    if (result.data.ok) {
+      // 重试成功,更新任务状态
+      const item = state.data.find((item) => item.task_id === taskId);
+      if (item) {
+        item.status = 'SENDING';
+        item.retry_count = (item.retry_count || 0) + 1;
+      }
+    }
+  },
+  rejected: (
+    state: Draft<EntitiesState<SendJob>>,
+    action: any
+  ) => {
+    console.error('重试发送任务 rejected:', action.error);
+  },
+});
+
+/**
+ * 创建发送任务列表的 Slices
+ */
+const {
+  entitiesSlice,
+  filtersSlice,
+  paginationSlice,
+  selectionSlice,
+  uiSlice,
+} = createEntityListSlices<SendJob, SendJobFilter>(
+  'sendJob',
+  fetchSendQueueThunk,
+  deleteSendJobThunk,
+  'task_id', // 使用 task_id 作为唯一标识符
+  undefined, // 不需要额外的 filter reducers
+  {
+    start_time: undefined,
+    end_time: undefined,
+    page: 1,
+    page_size: 10,
+  } satisfies SendJobFilter,
+  {
+    // 添加重试操作
+    retry: {
+      thunk: retrySendJobThunk,
+      handlers: createRetryHandlers(),
+    },
+  }
+);
+
+// 导出各个 slice
+export const sendJobEntitiesSlice = entitiesSlice;
+export const sendJobFiltersSlice = filtersSlice;
+export const sendJobPaginationSlice = paginationSlice;
+export const sendJobSelectionSlice = selectionSlice;
+export const sendJobUISlice = uiSlice;
+
+// 导出 actions(方便在组件中使用)
+export const sendJobActions = {
+  // Entities actions
+  ...entitiesSlice.actions,
+  // Filters actions
+  setStartTime: (startTime?: string) => 
+    filtersSlice.actions.setFilters({ start_time: startTime }),
+  setEndTime: (endTime?: string) => 
+    filtersSlice.actions.setFilters({ end_time: endTime }),
+  // Pagination actions
+  ...paginationSlice.actions,
+  // Selection actions
+  ...selectionSlice.actions,
+  // UI actions
+  ...uiSlice.actions,
+};

+ 13 - 0
src/states/output/sendJob/types/sendJobFilter.ts

@@ -0,0 +1,13 @@
+/**
+ * 发送任务筛选条件
+ */
+export interface SendJobFilter {
+  /** 开始时间 (RFC3339Nano 格式) */
+  start_time?: string;
+  /** 结束时间 (RFC3339Nano 格式) */
+  end_time?: string;
+  /** 当前页码 */
+  page: number;
+  /** 每页数量 */
+  page_size: number;
+}

+ 12 - 0
src/states/store.ts

@@ -36,6 +36,13 @@ import {
   historySelectionSlice,
   historyUISlice,
 } from './patient/worklist/slices/history';
+import {
+  sendJobEntitiesSlice,
+  sendJobFiltersSlice,
+  sendJobPaginationSlice,
+  sendJobSelectionSlice,
+  sendJobUISlice,
+} from './output/sendJob/slices/sendJobSlice';
 import generatorMonitorReducer from './exam/generatorMonitorSlice';
 import largeScreenReducer from './exam/largeScreenSlice';
 import deviceAreaReducer from './exam/deviceAreaSlice';
@@ -91,6 +98,11 @@ const store = configureStore({
     historyPagination: historyPaginationSlice.reducer,
     historySelection: historySelectionSlice.reducer,
     historyUI: historyUISlice.reducer,
+    sendJobEntities: sendJobEntitiesSlice.reducer,
+    sendJobFilters: sendJobFiltersSlice.reducer,
+    sendJobPagination: sendJobPaginationSlice.reducer,
+    sendJobSelection: sendJobSelectionSlice.reducer,
+    sendJobUI: sendJobUISlice.reducer,
     search: searchReducer,
     generatorMonitor: generatorMonitorReducer,
     largeScreen: largeScreenReducer,