Browse Source

RIS : 实现保存本地的功能

dengdx 1 month ago
parent
commit
48620f3fdb

+ 391 - 0
docs/实现/RIS保存本地功能实现方案.md

@@ -0,0 +1,391 @@
+# RIS保存本地功能实现方案
+
+## 概述
+
+RIS(Radiology Information System,放射信息系统)保存本地功能允许医务人员将从RIS系统同步过来的检查请求数据批量保存到本地工作列表中,该功能提升了RIS数据的管理效率,实现本地检查流程的无缝衔接。本文档详细描述了该功能的需求规格、实现设计、数据流和测试方案。
+
+## 需求规格
+
+### 功能描述
+实现【RIS保存本地】功能,用于将RIS数据批量保存到本地系统,支持医务人员在worklist视图中批量处理RIS检查请求。
+
+### 业务意义
+- **提升效率**:支持批量保存多个RIS条目,减少重复操作
+- **标准化流程**:统一RIS数据进入本地工作流程的标准做法
+- **数据同步**:确保RIS系统与本地工作站的数据一致性
+- **用户体验**:提供直观的操作界面,成功后自动刷新列表
+
+### 涉及接口
+- **后端接口**:`src/API/patient/risActions.ts` 中的 `saveRisBatch` (批量保存本地接口)
+- **请求参数**:`RisSaveBatchRequest` 类型(entry_id 字符串数组)
+- **响应**:`StandardApiResponse`
+
+### UI界面需求
+- **触发按钮**:在 `src/pages/patient/components/ActionPanel.tsx` 中新增"保存本地"按钮
+- **按钮可见性**:仅在 `currentKey` 为 `'worklist'` 时显示该按钮
+- **按钮可用性**:worklist列表中选中至少一个RIS数据时才可用(依据:选中项的 `entry_id` 不为空)
+- **前后端调用**:传递worklist列表中选中所有条目的 `entry_id` 形成的数组给后端
+
+### 流程需求
+1. 用户在worklist中选中具备entry_id的条目
+2. 点击"保存本地"按钮
+3. 前端调用 `saveRisBatch(entryIds)` 接口
+4. 后端处理成功后,前端弹出暂逝提示弹框
+5. 自动触发基于当前查询条件的列表刷新
+
+## 参与者清单(从粗到细)
+
+### 大类参与者:
+- **用户**:触发功能的发起者
+- **前端UI组件**:负责显示按钮、处理点击事件
+- **前端状态管理**:Redux store,管理选中项、搜索条件、面板状态
+- **前端API调用层**:负责后端接口调用
+- **后端服务**:处理批量保存请求,返回结果
+- **前端工具函数**:处理自动拉取列表逻辑
+
+### 具体组件/类/方法:
+- **组件层**:
+  - `src/pages/patient/components/ActionPanel.tsx` - 主动作面板组件
+    - `ActionPanel` 组件:包含所有按钮和逻辑
+    - `ActionButton` 组件:按钮包装组件
+
+- **API层**:
+  - `src/API/patient/risActions.ts`:RIS相关API
+    - `saveRisBatch(entryIds: RisSaveBatchRequest)` - 批量保存接口函数
+
+- **状态管理层**:
+  - Redux slices:
+    - `workSelection` slice: `selectedIds` - worklist选中ID
+    - `historySelection` slice: `selectedIds` - history选中ID
+    - `BusinessFlow` slice: `currentKey` - 当前面板标识
+    - `workEntities` slice: `data` - worklist条目数据
+    - `historyEntities` slice: `data` - history条目数据
+    - `search` slice: 查询条件状态
+
+- **工具函数**:
+  - 触发搜索的函数(将在 `ActionPanel.tsx` 中实现)
+
+- **UI元素**:
+  - "保存本地"按钮(新增)
+  - 成功提示弹框(使用 Ant Design Modal 或 message)
+
+- **数据模型**:
+  - Worklist条目数据结构:包含 `entry_id`、`StudyID` 等字段
+
+## 实现任务清单
+
+### 修改文件:
+1. **src/pages/patient/components/ActionPanel.tsx**
+   - 添加新按钮组件定义:"保存本地"按钮
+   - 修改按钮列表渲染逻辑:只有 `currentKey === 'worklist'` 时显示该按钮
+   - 添加 `handleRisSave` 方法:
+     - 检查选中项是否有至少一个 `entry_id` 不为空的条目
+     - 收集所有选中条目的 `entry_id`
+     - 调用 `saveRisBatch(entryIds)`
+     - 处理成功:显示暂逝弹框提示,然后触发自动拉取列表
+     - 处理失败:显示错误提示
+   - 添加按钮可用性控制逻辑:基于选中项和 `entry_id` 检查
+
+2. **src/API/patient/risActions.ts**
+   - 无需修改,已有 `saveRisBatch` 函数
+
+3. **src/states/patient/worklist/slices/workSlice.ts**
+   - 无需修改,但依赖其 `fetchWorkThunk` 用于自动拉取
+
+### 新增方法/类型:
+- 在 `ActionPanel.tsx` 中新增:
+  - `handleRisSave` - 处理RIS保存逻辑的方法
+  - `showSaveSuccessModal` - 显示成功提示弹框的方法(类似现有 `showSyncSuccessModal`)
+  - `getRisEnabledSelectedIds` - 获取RIS可用选中ID的方法
+
+- 无需新增类型:使用现有 `RisSaveBatchRequest` 和其他已定义类型
+
+### 删除:
+- 无(纯增量功能)
+
+## 参与者交互泳道图
+
+```mermaid
+sequenceDiagram
+    participant 用户
+    participant ActionPanel组件
+    participant 状态管理
+    participant API调用层
+    participant 后端服务
+    participant 查询系统
+
+    用户->>ActionPanel组件: 点击"保存本地"按钮
+    ActionPanel组件->>ActionPanel组件: 检查可用性(entry_id不为空的选中项)
+    ActionPanel组件->>ActionPanel组件: 收集entry_ids数组
+    ActionPanel组件->>API调用层: 调用saveRisBatch(entryIds)
+    API调用层->>后端服务: POST /auth/study/ris/batch
+    后端服务->>API调用层: 返回StandardApiResponse
+    API调用层->>ActionPanel组件: 成功/失败响应
+    ActionPanel组件->>状态管理: 更新loading状态
+    ActionPanel组件->>ActionPanel组件: 显示成功弹框(暂逝)
+    ActionPanel组件->>ActionPanel组件: 触发triggerSearch函数
+    triggerSearch->>状态管理: 重置分页(1,10)
+    triggerSearch->>状态管理: 获取search条件
+    triggerSearch->>查询系统: 调用fetchWorkThunk
+    查询系统->>状态管理: 更新workEntities数据
+    状态管理->>ActionPanel组件: 通知UI更新
+```
+
+## 数据流模型
+
+```
+数据流:
+======================= 输入数据 =======================
+├── 用户选中项ID数组 ← workSelection.selectedIds
+├── 当前面板标识 ← BusinessFlow.currentKey
+├── 工作列表实体数据 ← workEntities.data
+└── 查询条件 ← search slice (id, name, acc_no, start_time, end_time)
+
+======================= 中间处理 =======================
+├── 过滤逻辑:选中ID + workEntities → 筛选entry_id≠null的条目 → entryIds数组
+├── API调用:entryIds → saveRisBatch API → StandardApiResponse
+
+======================= 输出结果 =======================
+├── 成功时:暂逝提示弹框 + 触发 fetchWorkThunk 更新列表
+└── 失败时:错误提示弹框
+```
+
+## 数据结构定义
+
+### 请求数据结构
+```typescript
+type RisSaveBatchRequest = string[]; // entry_id 字符串数组
+```
+
+### 响应数据结构
+```typescript
+interface StandardApiResponse {
+  code: string;
+  description: string;
+  solution: string;
+  data: EmptyResponseData;
+}
+```
+
+### 内部使用数据结构
+```typescript
+// Worklist条目
+interface WorkItem {
+  StudyID: string;
+  entry_id?: string; // RIS条目ID,不为空表示RIS数据
+  PatientName?: string;
+  // ... 其他字段
+}
+
+// 查询条件
+interface SearchState {
+  id?: string;
+  name?: string;
+  acc_no?: string;
+  start_time?: string;
+  end_time?: string;
+}
+```
+
+## 执行流程详述
+
+### 起点
+**起点:用户操作** - 在worklist视图下,选中一条或多条RIS数据后,点击"保存本地"按钮。
+
+### 完整执行流程
+1. **前提检查**:Confirm in worklist面板(`currentKey === 'worklist'`),选中至少一条含有entry_id的条目
+2. **用户触发**:点击"保存本地"按钮
+3. **可用性验证**:前端检查是否有entry_id不为空的选中项
+4. **数据收集**:从选中项中提取所有entry_id,形成字符串数组
+5. **API调用**:调用`saveRisBatch(entryIds)`,传入entry_id数组
+6. **请求发送**:POST /auth/study/ris/batch,body包含entryIds
+7. **后端处理**:后端接收entryIds,批量保存到本地
+8. **响应返回**:返回`StandardApiResponse`
+9. **成功处理**:
+   - 显示暂逝成功弹框(显示保存数量)
+   - 触发自动拉取:基于search条件调用`fetchWorkThunk`
+10. **失败处理**:显示错误提示弹框
+11. **结束**:列表自动刷新,用户看到更新结果
+
+### 流程异常处理
+- **网络超时**:显示连接超时提示,不刷新列表
+- **服务器错误**:根据错误码显示对应错误信息
+- **权限不足**:显示权限不足提示
+- **数据异常**:显示数据处理失败提示
+
+## 类图设计
+
+```mermaid
+classDiagram
+    class ActionPanel {
+        +currentKey: string
+        +workSelectedIds: string[]
+        +workEntities: WorkItem[]
+        +searchState: SearchState
+        +risSaving: boolean
+        +handleRisSave()
+        +getRisSelectedIds()
+        +showSaveSuccessModal(count)
+        +triggerSearch()
+    }
+
+    class RisManager {
+        +<<static>> isRisEnabled(): Promise~boolean~
+        +<<static>> saveAllRisEntries(): Promise~number~
+        +<<static>> syncTodayRis(): Promise~number~
+    }
+
+    class RisActions {
+        +saveRisBatch(entryIds): Promise~StandardApiResponse~
+        +saveRisSingle(params): Promise~RegisterWorkResponse~
+        +syncRis(params): Promise~RisSyncResponse~
+    }
+
+    class ReduxStore {
+        +workSelection: WorkSelectionSlice
+        +workEntities: WorkEntitiesSlice
+        +search: SearchSlice
+        +BusinessFlow: BusinessFlowSlice
+    }
+
+    ActionPanel --> RisActions : 调用API
+    ActionPanel --> ReduxStore : 读取/更新状态
+    RisActions --> RisManager : 扩展功能
+    RisManager --> RisActions : 底层调用
+```
+
+## 测试方案制定
+
+### 🧪 测试场景列表
+
+#### 功能测试场景
+1. **正常保存场景**
+   - 前置:worklist中有多条RIS数据可选
+   - 操作:选中2-3条RIS数据,点击"保存本地"
+   - 预期:API调用成功,显示"成功保存X条数据"弹框,弹框自动消失,列表刷新
+
+2. **边界条件测试**
+   - 操作:只有一个选中项,点击保存
+   - 操作:选中5+条以上数据,点击保存
+   - 预期:正常处理,无性能问题
+
+3. **可用性控制测试**
+   - 前置:选中非RIS数据(无entry_id)
+   - 预期:按钮禁用,不可用
+   - 前置:切换到history面板
+   - 预期:按钮不显示
+
+4. **错误处理测试**
+   - 模拟网络异常或API失败
+   - 预期:显示错误提示弹框,列表不刷新
+
+5. **自动刷新测试**
+   - 修改查询条件后保存
+   - 预期:刷新时应用新的查询条件
+
+#### 性能测试场景
+6. **大批量保存测试**
+   - 操作:选中50条RIS数据批量保存
+   - 预期:响应时间<3秒,无卡顿
+
+7. **并发操作测试**
+   - 操作:快速连续点击保存按钮
+   - 预期:防重复提交,后续操作被忽略
+
+#### 兼容性测试场景
+8. **数据兼容测试**
+   - 前置:混合选中RIS和非RIS数据
+   - 预期:只处理RIS数据,忽略非RIS数据
+
+### 🔧 测试操作步骤
+
+#### 前置条件
+- 应用已启动,登录后进入patient页面
+- 当前在worklist tab
+- worklist中存在RIS数据条目(具备entry_id字段)
+
+#### 成功测试步骤
+1. **进入worklist视图**:确认当前面板为worklist,显示"保存本地"按钮
+2. **选中RIS数据**:选择一条或多条包含entry_id的worklist条目
+3. **点击保存本地**:点击"保存本地"按钮(按钮应可用状态)
+4. **观察调用过程**:查看控制台日志,确认调用saveRisBatch API
+5. **成功场景验证**:
+   - API返回成功(code: "0x000000")
+   - 显示暂逝提示弹框:"成功保存X条RIS数据到本地"
+   - 弹框自动消失(3-5秒)
+   - 列表自动刷新,显示最新数据
+
+#### 失败测试步骤
+6. **模拟失败场景**:通过开发者工具阻塞网络或返回错误码
+7. **触发保存**:点击按钮触发API调用
+8. **验证错误处理**:显示错误提示弹框,包含错误信息;列表不刷新
+
+#### 边界测试步骤
+9. **无选中测试**:不选中任何条目,按钮应禁用
+10. **非RIS数据测试**:选中无entry_id条目,按钮应禁用
+11. **面板切换测试**:切换到history,按钮应消失
+
+## 🐛 潜在问题分析
+
+### 1. 并发冲突问题
+- **场景**:用户快速连续点击保存按钮
+- **影响**:可能引起重复保存或API竞态
+- **解决方案**:
+  - 添加deboucing机制,连续请求间隔至少1秒
+  - 使用loading状态禁用按钮直到请求完成
+  - 后端增加幂等性检查
+
+### 2. 数据一致性问题
+- **场景**:保存过程中,其他用户修改了相同RIS数据
+- **影响**:可能造成数据覆盖或冲突
+- **解决方案**:
+  - 获取entry_id时同时获取版本号
+  - 后端检查版本冲突并返回适当错误
+
+### 3. 大批量处理性能问题
+- **场景**:选中大量条目(>100)进行批量保存
+- **影响**:前端/后端响应慢,用户体验差
+- **解决方案**:
+  - 前端限制单次最多50个条目
+  - 显示进度条和处理状态
+  - 支持分页批量处理
+
+### 4. 网络异常容错
+- **场景**:网络断开或后端服务失效
+- **影响**:用户无法知晓保存状态
+- **解决方案**:
+  - 实现本地暂存机制,网络恢复后重试
+  - 提供离线状态指示
+  - 详细的错误信息提示
+
+### 5. UI状态同步问题
+- **场景**:Redux状态更新与UI渲染不同步
+- **影响**:按钮状态显示错误,造成困惑
+- **解决方案**:
+  - 使用useEffect监控状态变化
+  - 統一状态管理逻辑
+  - 添加loading skeleton或spinner
+
+### 6. 查询条件失效问题
+- **场景**:保存后自动拉取使用错误的查询条件
+- **影响**:列表刷新后不显示预期数据
+- **解决方案**:
+  - 确保从正确的search slice获取条件
+  - 添加查询条件校验
+  - 日志记录实际使用的查询参数
+
+### 7. 权限控制缺失问题
+- **场景**:当前用户无RIS保存权限
+- **影响**:用户可看到按钮但操作失败
+- **解决方案**:
+  - 前端预检用户权限,隐藏按钮
+  - 后端权限校验,返回适当错误码
+  - 统一的权限管理框架
+
+### 8. 浏览器兼容性问题
+- **场景**:不同浏览器对Modal组件行为差异
+- **影响**:弹框消失时间不一致
+- **解决方案**:
+  - 使用统一的时间管理库
+  - 测试所有目标浏览器
+  - 提供配置化的弹出时长

+ 108 - 1
src/pages/patient/components/ActionPanel.tsx

@@ -20,7 +20,7 @@ import { setBusinessFlow } from '@/states/BusinessFlowSlice';
 import { setSourceTask, setRegisterInfo } from '@/states/patient/reregister/reregisterSlice';
 import { mapTaskToRegisterInfo } from '@/domain/patient/taskToRegister';
 import { showNotImplemented } from '@/utils/notificationHelper';
-import { syncRis, RisSyncRequest } from '@/API/patient/risActions';
+import { syncRis, RisSyncRequest, saveRisBatch } from '@/API/patient/risActions';
 import { fetchBinThunk } from '@/states/patient/bin/slices/binSlice';
 import { setPage, setPageSize } from '@/states/patient/worklist/slices/searchSlice';
 import { WorkFilter } from '@/states/patient/worklist/types/workfilter';
@@ -60,9 +60,14 @@ const ActionPanel: React.FC = () => {
   
   // RIS同步loading状态
   const [risSyncing, setRisSyncing] = useState(false);
+  // RIS保存本地loading状态
+  const [risSaving, setRisSaving] = useState(false);
 
   const workSelectedIds = useSelector(
     (state: RootState) => state.workSelection.selectedIds
+  );
+    const workSelectedSecondaryIdsIds = useSelector(
+    (state: RootState) => state.workSelection.selectedSecondaryIds
   );
   const historySelectedIds = useSelector(
     (state: RootState) => state.historySelection.selectedIds
@@ -368,6 +373,84 @@ const ActionPanel: React.FC = () => {
     }
   }, [searchState, showSyncSuccessModal, showSyncErrorModal, triggerSearch]);
 
+  // 获取RIS可用选中项的entry_id数组
+  const getRisSelectedIds = useCallback(() => {
+    if (currentKey !== 'worklist') return [];
+
+    const selectedIds = workSelectedSecondaryIdsIds;
+    if (selectedIds.length === 0) return [];
+
+    return workEntities
+      .filter(work => selectedIds.includes(work.entry_id??''))
+      .map(work => work.entry_id!);
+  }, [currentKey, workSelectedSecondaryIdsIds, workEntities]);
+
+  // RIS保存成功弹框
+  const showSaveSuccessModal = useCallback((count: number) => {
+    Modal.success({
+      title: '🎉 保存成功',
+      content: (
+        <div style={{ padding: '16px 0' }}>
+          <p style={{ fontSize: '16px', margin: '8px 0' }}>
+            成功保存 <strong style={{ color: '#52c41a' }}>{count}</strong> 条RIS数据到本地
+          </p>
+          <p style={{ color: '#666', margin: '8px 0' }}>
+            系统将自动刷新列表显示最新数据
+          </p>
+        </div>
+      ),
+      okText: '确定',
+      centered: true,
+      afterClose: () => {
+        // 弹框自动消失后执行列表刷新
+        triggerSearch();
+      }
+    });
+  }, [triggerSearch]);
+
+  // RIS保存本地处理函数
+  const handleRisSave = useCallback(async () => {
+    try {
+      // 获取需要保存的entryIds
+      const entryIds = getRisSelectedIds();
+
+      if (entryIds.length === 0) {
+        message.warning('请先选择至少一个RIS数据(具备entry_id的条目)');
+        return;
+      }
+
+      setRisSaving(true);
+      console.log('RIS保存本地参数:', `共${entryIds.length}个条目`, entryIds);
+
+      // 调用RIS批量保存API
+      await saveRisBatch(entryIds);
+
+      console.log('RIS保存本地成功');
+
+      // 显示成功弹框(暂逝后自动刷新)
+      showSaveSuccessModal(entryIds.length);
+
+    } catch (error) {
+      console.error('RIS保存本地失败:', error);
+      const errorMessage = error instanceof Error ? error.message : '保存过程中发生未知错误';
+      Modal.error({
+        title: '❌ 保存失败',
+        content: (
+          <div style={{ padding: '16px 0' }}>
+            <p style={{ margin: '8px 0' }}>{errorMessage}</p>
+            <p style={{ color: '#666', fontSize: '14px' }}>
+              请检查网络连接或联系技术支持
+            </p>
+          </div>
+        ),
+        okText: '确定',
+        centered: true
+      });
+    } finally {
+      setRisSaving(false);
+    }
+  }, [getRisSelectedIds, showSaveSuccessModal]);
+
   return (
     <div className="flex flex-wrap gap-2 w-full">
       <ActionButton
@@ -450,6 +533,30 @@ const ActionPanel: React.FC = () => {
         disabled={risSyncing}
         data-testid="ris-sync-button"
       />)}
+      {currentKey === 'worklist' && (
+        <ActionButton
+          icon={
+            <Icon
+              module="module-patient"
+              name="Save"
+              userId="base"
+              theme="default"
+              size="2x"
+              state="normal"
+            />
+          }
+          tooltip={
+            <FormattedMessage
+              id="actionPanel.saveRisLocal"
+              defaultMessage="保存本地"
+            />
+          }
+          onClick={handleRisSave}
+          loading={risSaving}
+          disabled={risSaving || getRisSelectedIds().length === 0}
+          data-testid="ris-save-button"
+        />
+      )}
       {currentKey === 'historylist' && (
       <ActionButton
         icon={