|
|
@@ -0,0 +1,851 @@
|
|
|
+# 从任务清单进入检查的流程
|
|
|
+
|
|
|
+## 📋 文档概述
|
|
|
+
|
|
|
+**文档名称**: 从任务清单进入检查的流程总结
|
|
|
+
|
|
|
+**创建日期**: 2025-12-22
|
|
|
+
|
|
|
+**文档目的**: 全面总结从 Worklist/History 任务清单双击进入检查或处理界面的完整业务流程
|
|
|
+
|
|
|
+**涵盖范围**:
|
|
|
+- 用户交互流程
|
|
|
+- 核心业务逻辑
|
|
|
+- 数据流转关系
|
|
|
+- Redux 状态管理
|
|
|
+- 路由导航机制
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📊 主流程泳道图
|
|
|
+
|
|
|
+```mermaid
|
|
|
+sequenceDiagram
|
|
|
+ participant User as 👤 用户
|
|
|
+ participant UI as 🖥️ UI层<br/>(Worklist/History)
|
|
|
+ participant Domain as ⚙️ Domain层<br/>(worklistToExam)
|
|
|
+ participant API as 🌐 后端API
|
|
|
+ participant Redux as 📦 Redux Store
|
|
|
+ participant Router as 🔀 路由系统
|
|
|
+
|
|
|
+ User->>UI: 双击任务行
|
|
|
+ UI->>Domain: worklistToExam(task)
|
|
|
+
|
|
|
+ Note over Domain: 1. 准备数据阶段
|
|
|
+ Domain->>API: fetchTaskDetails(StudyID)
|
|
|
+ API-->>Domain: 返回完整Study数据<br/>(series + images)
|
|
|
+
|
|
|
+ Note over Domain: 2. 数据转换阶段
|
|
|
+ Domain->>Domain: 转换为Task格式<br/>(包含所有Views)
|
|
|
+
|
|
|
+ Note over Domain: 3. 曝光状态判断
|
|
|
+ Domain->>Domain: 检查所有Views的<br/>expose_status
|
|
|
+
|
|
|
+ alt 所有体位已曝光
|
|
|
+ Note over Domain: 分支A: 进入处理界面
|
|
|
+ Domain->>Redux: clearWorks()
|
|
|
+ Domain->>Redux: addWork(updatedTask)
|
|
|
+ Domain->>Domain: transformWorksToBodyPositions()
|
|
|
+ Domain->>Redux: setBodyPositions(bodyPositions)
|
|
|
+ Domain->>Redux: setBusinessFlow('process')
|
|
|
+ Redux-->>Router: 触发路由变更
|
|
|
+ Router-->>User: 导航到处理界面
|
|
|
+ else 存在未曝光体位
|
|
|
+ Note over Domain: 分支B: 进入检查界面
|
|
|
+ Domain->>Redux: clearWorks()
|
|
|
+ Domain->>Redux: addWork(updatedTask)
|
|
|
+ Domain->>Redux: setBusinessFlow('exam')
|
|
|
+ Redux-->>Router: 触发路由变更
|
|
|
+ Router-->>User: 导航到检查界面
|
|
|
+ end
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🔀 核心决策流程图
|
|
|
+
|
|
|
+```mermaid
|
|
|
+flowchart TD
|
|
|
+ Start([用户双击任务]) --> Fetch[调用 fetchTaskDetails API]
|
|
|
+ Fetch --> Transform[转换数据格式<br/>Series + Images → Views]
|
|
|
+ Transform --> Check{检查曝光状态}
|
|
|
+
|
|
|
+ Check -->|Views.length > 0 &&<br/>所有 expose_status === 'Exposed'| AllExposed[所有体位已曝光]
|
|
|
+ Check -->|存在任何<br/>expose_status !== 'Exposed'| HasUnexposed[存在未曝光体位]
|
|
|
+
|
|
|
+ AllExposed --> ClearA[清空缓存 clearWorks]
|
|
|
+ ClearA --> AddA[保存任务 addWork]
|
|
|
+ AddA --> TransformBP[转换为体位列表<br/>transformWorksToBodyPositions]
|
|
|
+ TransformBP --> SetBP[设置体位数据<br/>setBodyPositions]
|
|
|
+ SetBP --> ToProcess[设置流程为 'process']
|
|
|
+ ToProcess --> ProcessPage([进入处理界面])
|
|
|
+
|
|
|
+ HasUnexposed --> ClearB[清空缓存 clearWorks]
|
|
|
+ ClearB --> AddB[保存任务 addWork]
|
|
|
+ AddB --> ToExam[设置流程为 'exam']
|
|
|
+ ToExam --> ExamPage([进入检查界面])
|
|
|
+
|
|
|
+ style AllExposed fill:#90EE90
|
|
|
+ style HasUnexposed fill:#FFB6C1
|
|
|
+ style ProcessPage fill:#4169E1,color:#fff
|
|
|
+ style ExamPage fill:#FF8C00,color:#fff
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🏊 完整业务泳道图(包含多个入口)
|
|
|
+
|
|
|
+```mermaid
|
|
|
+flowchart TB
|
|
|
+ subgraph UserActions[用户操作层]
|
|
|
+ W[Worklist双击]
|
|
|
+ H[History双击]
|
|
|
+ end
|
|
|
+
|
|
|
+ subgraph DomainLogic[业务逻辑层]
|
|
|
+ WTE[worklistToExam函数]
|
|
|
+ PWE[prepareWorksForExam<br/>准备详细数据]
|
|
|
+
|
|
|
+ subgraph DataProcess[数据处理]
|
|
|
+ FetchAPI[调用 fetchTaskDetails API]
|
|
|
+ MapData[映射数据:<br/>Series → Views<br/>Images → dview]
|
|
|
+ end
|
|
|
+
|
|
|
+ subgraph Decision[决策逻辑]
|
|
|
+ CheckStatus{检查曝光状态<br/>allExposed?}
|
|
|
+ end
|
|
|
+ end
|
|
|
+
|
|
|
+ subgraph StateManagement[状态管理层 Redux]
|
|
|
+ ClearCache[clearWorks<br/>清空缓存]
|
|
|
+ AddCache[addWork<br/>保存任务]
|
|
|
+ SetBP[setBodyPositions<br/>设置体位列表]
|
|
|
+ SetFlow[setBusinessFlow<br/>设置业务流程]
|
|
|
+ end
|
|
|
+
|
|
|
+ subgraph Routing[路由层]
|
|
|
+ ToExamRoute[导航到 /exam]
|
|
|
+ ToProcessRoute[导航到 /process]
|
|
|
+ end
|
|
|
+
|
|
|
+ subgraph Pages[页面层]
|
|
|
+ ExamPage[📋 检查界面<br/>Exam Page]
|
|
|
+ ProcessPage[🖼️ 处理界面<br/>Process Page]
|
|
|
+ end
|
|
|
+
|
|
|
+ W --> WTE
|
|
|
+ H --> WTE
|
|
|
+ WTE --> PWE
|
|
|
+ PWE --> FetchAPI
|
|
|
+ FetchAPI --> MapData
|
|
|
+ MapData --> CheckStatus
|
|
|
+
|
|
|
+ CheckStatus -->|是<br/>所有体位已曝光| PathA[处理路径]
|
|
|
+ CheckStatus -->|否<br/>存在未曝光体位| PathB[检查路径]
|
|
|
+
|
|
|
+ PathA --> ClearCache
|
|
|
+ ClearCache --> AddCache
|
|
|
+ AddCache --> SetBP
|
|
|
+ SetBP --> SetFlowProcess[setBusinessFlow<br/>'process']
|
|
|
+ SetFlowProcess --> ToProcessRoute
|
|
|
+ ToProcessRoute --> ProcessPage
|
|
|
+
|
|
|
+ PathB --> ClearCache2[clearWorks]
|
|
|
+ ClearCache2 --> AddCache2[addWork]
|
|
|
+ AddCache2 --> SetFlowExam[setBusinessFlow<br/>'exam']
|
|
|
+ SetFlowExam --> ToExamRoute
|
|
|
+ ToExamRoute --> ExamPage
|
|
|
+
|
|
|
+ style W fill:#E6F3FF
|
|
|
+ style H fill:#E6F3FF
|
|
|
+ style CheckStatus fill:#FFF4E6
|
|
|
+ style ExamPage fill:#FF8C00,color:#fff
|
|
|
+ style ProcessPage fill:#4169E1,color:#fff
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📋 详细流程说明
|
|
|
+
|
|
|
+### 阶段1: 用户触发操作
|
|
|
+
|
|
|
+**入口点**:
|
|
|
+- Worklist 页面双击任务行
|
|
|
+- History 页面双击任务行
|
|
|
+
|
|
|
+**触发函数**:
|
|
|
+- `handleRowDoubleClick(record: Task)` → `worklistToExam(task)`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 阶段2: 数据准备 (prepareWorksForExam)
|
|
|
+
|
|
|
+**输入**: `Task` 对象(可能包含不完整的 Views 数据)
|
|
|
+
|
|
|
+**处理流程**:
|
|
|
+1. 调用 `fetchTaskDetails(StudyID)` 获取完整的 Study 数据
|
|
|
+2. 接收后端返回的 `series` 和 `images` 数据
|
|
|
+3. 使用 `flatMap` 将 Series 和 Images 转换为 `dview[]` 格式
|
|
|
+4. 构建包含完整 Views 的 Task 对象
|
|
|
+
|
|
|
+**输出**: `Task` 对象(包含完整的 Views 数据)
|
|
|
+
|
|
|
+**数据映射关系**:
|
|
|
+```typescript
|
|
|
+TaskDetails.series[] → flatMap → Task.Views[]
|
|
|
+ ├─ Series.images[] → map → dview[]
|
|
|
+ └─ 每个 Image 映射为一个 dview 对象
|
|
|
+```
|
|
|
+
|
|
|
+**dview 字段映射**:
|
|
|
+| 源字段 (Image/Series) | 目标字段 (dview) | 说明 |
|
|
|
+|----------------------|------------------|------|
|
|
|
+| `image.view_id` | `view_id` | 体位ID |
|
|
|
+| `series.series_instance_uid` | `series_instance_uid` | Series UID |
|
|
|
+| `taskDetails.study_instance_uid` | `study_instance_uid` | Study UID |
|
|
|
+| `image.sop_instance_uid` | `PrimarySopUID` | 图像 SOP UID |
|
|
|
+| `image.expose_status` | `expose_status` | **曝光状态(关键)** |
|
|
|
+| `image.image_file_path` | `image_file` | 图像文件路径 |
|
|
|
+| `image.thumbnail_file` | `thumbnail_file` | 缩略图路径 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 阶段3: 曝光状态判断 ⚠️ **核心决策点**
|
|
|
+
|
|
|
+**判断逻辑**:
|
|
|
+```typescript
|
|
|
+const allExposed =
|
|
|
+ updatedTask.Views.length > 0 &&
|
|
|
+ updatedTask.Views.every((view) => view.expose_status === 'Exposed');
|
|
|
+```
|
|
|
+
|
|
|
+**决策规则**:
|
|
|
+
|
|
|
+| 条件 | 判断结果 | 目标界面 |
|
|
|
+|------|---------|---------|
|
|
|
+| `Views.length === 0` | `allExposed = false` | Exam(检查界面) |
|
|
|
+| `Views.length > 0` 且所有 `expose_status === 'Exposed'` | `allExposed = true` | **Process(处理界面)** |
|
|
|
+| `Views.length > 0` 但存在 `expose_status !== 'Exposed'` | `allExposed = false` | Exam(检查界面) |
|
|
|
+
|
|
|
+**状态值说明**:
|
|
|
+- `'Exposed'` - 已曝光(已拍摄图像)
|
|
|
+- `'Unexposed'` - 未曝光(待拍摄)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 阶段4: Redux 状态更新
|
|
|
+
|
|
|
+#### 4A. 进入处理界面 (allExposed === true)
|
|
|
+
|
|
|
+**步骤顺序**:
|
|
|
+```typescript
|
|
|
+1. dispatch(clearWorks()) // 清空现有工作缓存
|
|
|
+2. dispatch(addWork(updatedTask)) // 保存新任务到缓存
|
|
|
+3. bodyPositions = await transformWorksToBodyPositions([updatedTask]) // 转换为体位列表
|
|
|
+4. dispatch(setBodyPositions(bodyPositions)) // 设置体位列表数据
|
|
|
+5. dispatch(setBusinessFlow('process')) // 切换业务流程到 process
|
|
|
+```
|
|
|
+
|
|
|
+**Redux 状态变化**:
|
|
|
+| Slice | Action | 状态变化 |
|
|
|
+|-------|--------|---------|
|
|
|
+| `examWorksCache` | `clearWorks` | `works: []` |
|
|
|
+| `examWorksCache` | `addWork` | `works: [updatedTask]` |
|
|
|
+| `bodyPositionList` | `setBodyPositions` | `positions: ExtendedBodyPosition[]` |
|
|
|
+| `businessFlow` | `setBusinessFlow` | `currentKey: 'process'` |
|
|
|
+
|
|
|
+**关键依赖**: 必须先设置 `bodyPositions`,否则处理界面无法正常显示
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+#### 4B. 进入检查界面 (allExposed === false)
|
|
|
+
|
|
|
+**步骤顺序**:
|
|
|
+```typescript
|
|
|
+1. dispatch(clearWorks()) // 清空现有工作缓存
|
|
|
+2. dispatch(addWork(updatedTask)) // 保存新任务到缓存
|
|
|
+3. dispatch(setBusinessFlow('exam')) // 切换业务流程到 exam
|
|
|
+```
|
|
|
+
|
|
|
+**Redux 状态变化**:
|
|
|
+| Slice | Action | 状态变化 |
|
|
|
+|-------|--------|---------|
|
|
|
+| `examWorksCache` | `clearWorks` | `works: []` |
|
|
|
+| `examWorksCache` | `addWork` | `works: [updatedTask]` |
|
|
|
+| `businessFlow` | `setBusinessFlow` | `currentKey: 'exam'` |
|
|
|
+
|
|
|
+**说明**: 检查界面不需要 `bodyPositions`,因为会在界面内动态生成和管理
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 阶段5: 路由导航
|
|
|
+
|
|
|
+**触发机制**: `setBusinessFlow` 的 Redux 状态变化会触发路由监听
|
|
|
+
|
|
|
+**导航目标**:
|
|
|
+- `businessFlow.currentKey === 'exam'` → 导航到 `/exam` 路由
|
|
|
+- `businessFlow.currentKey === 'process'` → 导航到 `/process` 路由
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📊 数据流转关系表
|
|
|
+
|
|
|
+| 阶段 | 输入 | 处理函数/逻辑 | 输出 |
|
|
|
+|------|------|--------------|------|
|
|
|
+| **1. API 调用** | `StudyID: string` | `fetchTaskDetails(StudyID)` | `TaskDetails` (包含 series, images) |
|
|
|
+| **2. 数据转换** | `TaskDetails` | `Series.flatMap(s => s.images.map(...))` | `Task` (包含 Views) |
|
|
|
+| **3. 状态判断** | `Task.Views[]` | `Views.every(v => v.expose_status === 'Exposed')` | `boolean` (allExposed) |
|
|
|
+| **4A. 体位转换** | `Task[]` | `transformWorksToBodyPositions(tasks)` | `ExtendedBodyPosition[]` |
|
|
|
+| **4B. Redux 更新** | `Task`, `allExposed` | 条件分支 dispatch | Redux State 更新 |
|
|
|
+| **5. 路由导航** | `businessFlow.currentKey` | 路由监听 | 页面切换 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🎯 四种业务场景
|
|
|
+
|
|
|
+### 场景矩阵
|
|
|
+
|
|
|
+| 入口 | 曝光状态 | 判断条件 | 目标界面 | 测试文件 |
|
|
|
+|------|---------|---------|---------|---------|
|
|
|
+| **Worklist** | 未曝光 | `allExposed = false` | **Exam** | `worklist-enter-exam-unexposed.cy.ts` |
|
|
|
+| **Worklist** | 已曝光 | `allExposed = true` | **Process** | `worklist-enter-process-exposed.cy.ts` |
|
|
|
+| **History** | 未曝光 | `allExposed = false` | **Exam** | `history-enter-exam-unexposed.cy.ts` |
|
|
|
+| **History** | 已曝光 | `allExposed = true` | **Process** | `history-enter-process-exposed.cy.ts` |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 场景1: Worklist → Exam(未曝光)
|
|
|
+
|
|
|
+```mermaid
|
|
|
+sequenceDiagram
|
|
|
+ participant User
|
|
|
+ participant Worklist
|
|
|
+ participant worklistToExam
|
|
|
+ participant API
|
|
|
+ participant Redux
|
|
|
+ participant ExamPage
|
|
|
+
|
|
|
+ User->>Worklist: 双击未曝光任务
|
|
|
+ Worklist->>worklistToExam: worklistToExam(task)
|
|
|
+ worklistToExam->>API: fetchTaskDetails(StudyID)
|
|
|
+ API-->>worklistToExam: TaskDetails (有未曝光体位)
|
|
|
+ worklistToExam->>worklistToExam: allExposed = false
|
|
|
+ worklistToExam->>Redux: clearWorks() + addWork()
|
|
|
+ worklistToExam->>Redux: setBusinessFlow('exam')
|
|
|
+ Redux-->>ExamPage: 导航到检查界面
|
|
|
+```
|
|
|
+
|
|
|
+**关键验证点**:
|
|
|
+- ✅ API 返回包含 `expose_status: 'Unexposed'` 的 images
|
|
|
+- ✅ `businessFlow.currentKey === 'exam'`
|
|
|
+- ✅ `examWorksCache.works.length === 1`
|
|
|
+- ✅ 检查界面主要元素可见
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 场景2: Worklist → Process(已曝光)
|
|
|
+
|
|
|
+```mermaid
|
|
|
+sequenceDiagram
|
|
|
+ participant User
|
|
|
+ participant Worklist
|
|
|
+ participant worklistToExam
|
|
|
+ participant API
|
|
|
+ participant Redux
|
|
|
+ participant ProcessPage
|
|
|
+
|
|
|
+ User->>Worklist: 双击已曝光任务
|
|
|
+ Worklist->>worklistToExam: worklistToExam(task)
|
|
|
+ worklistToExam->>API: fetchTaskDetails(StudyID)
|
|
|
+ API-->>worklistToExam: TaskDetails (所有体位已曝光)
|
|
|
+ worklistToExam->>worklistToExam: allExposed = true
|
|
|
+ worklistToExam->>Redux: clearWorks() + addWork()
|
|
|
+ worklistToExam->>worklistToExam: transformWorksToBodyPositions()
|
|
|
+ worklistToExam->>Redux: setBodyPositions() + setBusinessFlow('process')
|
|
|
+ Redux-->>ProcessPage: 导航到处理界面
|
|
|
+```
|
|
|
+
|
|
|
+**关键验证点**:
|
|
|
+- ✅ API 返回所有 images 的 `expose_status === 'Exposed'`
|
|
|
+- ✅ `businessFlow.currentKey === 'process'`
|
|
|
+- ✅ `bodyPositionList.positions.length > 0`
|
|
|
+- ✅ 处理界面主要元素可见
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 场景3: History → Exam(未曝光)
|
|
|
+
|
|
|
+**流程**: 与场景1相同,仅入口点不同(从 History 页面触发)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 场景4: History → Process(已曝光)
|
|
|
+
|
|
|
+**流程**: 与场景2相同,仅入口点不同(从 History 页面触发)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🗂️ 核心文件清单
|
|
|
+
|
|
|
+### Domain 业务逻辑层
|
|
|
+
|
|
|
+| 文件路径 | 职责 | 关键函数 |
|
|
|
+|---------|------|---------|
|
|
|
+| `src/domain/patient/worklistToExam.ts` | **核心业务逻辑** | `worklistToExam()`, `prepareWorksForExam()`, `worklistToProcess()` |
|
|
|
+
|
|
|
+### UI 组件层
|
|
|
+
|
|
|
+| 文件路径 | 职责 | 关键函数/组件 |
|
|
|
+|---------|------|--------------|
|
|
|
+| `src/pages/patient/worklist.tsx` | Worklist 页面 | `handleRowDoubleClick()` |
|
|
|
+| `src/pages/patient/HistoryList.tsx` | History 页面 | `handleRowDoubleClick()` |
|
|
|
+| `src/pages/patient/components/WorklistTable.tsx` | 表格组件 | `onDoubleClick` 事件处理 |
|
|
|
+
|
|
|
+### Redux 状态管理层
|
|
|
+
|
|
|
+| 文件路径 | Slice 名称 | 关键 Actions |
|
|
|
+|---------|-----------|-------------|
|
|
|
+| `src/states/exam/examWorksCacheSlice.ts` | `examWorksCache` | `clearWorks()`, `addWork()` |
|
|
|
+| `src/states/exam/bodyPositionListSlice.ts` | `bodyPositionList` | `setBodyPositions()`, `transformWorksToBodyPositions()` |
|
|
|
+| `src/states/BusinessFlowSlice.ts` | `businessFlow` | `setBusinessFlow()` |
|
|
|
+
|
|
|
+### API 层
|
|
|
+
|
|
|
+| 文件路径 | 职责 | 关键函数 |
|
|
|
+|---------|------|---------|
|
|
|
+| `src/API/patient/workActions.ts` | 任务相关 API | `fetchTaskDetails()` |
|
|
|
+
|
|
|
+### 类型定义
|
|
|
+
|
|
|
+| 文件路径 | 定义类型 |
|
|
|
+|---------|---------|
|
|
|
+| `src/domain/work.ts` | `Task` 接口 |
|
|
|
+| `src/domain/dview.ts` | `dview` 接口(体位视图) |
|
|
|
+| `src/domain/series.ts` | `Series` 接口 |
|
|
|
+| `src/domain/xImage.ts` | `XImage` 接口 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🔍 核心代码片段
|
|
|
+
|
|
|
+### 1. worklistToExam 函数(完整逻辑)
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 文件: src/domain/patient/worklistToExam.ts
|
|
|
+const worklistToExam = async (task: Task) => {
|
|
|
+ const dispatch = store.dispatch;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 步骤1: 准备详细数据
|
|
|
+ const [updatedTask] = await prepareWorksForExam(task);
|
|
|
+
|
|
|
+ // 步骤2: 判断曝光状态
|
|
|
+ const allExposed =
|
|
|
+ updatedTask.Views.length > 0 &&
|
|
|
+ updatedTask.Views.every((view) => view.expose_status === 'Exposed');
|
|
|
+
|
|
|
+ // 步骤3: 清空并保存任务
|
|
|
+ dispatch(clearWorks());
|
|
|
+ dispatch(addWork(updatedTask));
|
|
|
+
|
|
|
+ // 步骤4: 根据状态决定流程
|
|
|
+ if (allExposed) {
|
|
|
+ // 所有体位已曝光 → 进入处理界面
|
|
|
+ const bodyPositions = await transformWorksToBodyPositions([updatedTask]);
|
|
|
+ dispatch(setBodyPositions(bodyPositions));
|
|
|
+ dispatch(setBusinessFlow('process'));
|
|
|
+ } else {
|
|
|
+ // 存在未曝光体位 → 进入检查界面
|
|
|
+ dispatch(setBusinessFlow('exam'));
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Error in worklistToExam:', error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2. prepareWorksForExam 函数(数据准备)
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 文件: src/domain/patient/worklistToExam.ts
|
|
|
+export const prepareWorksForExam = async (
|
|
|
+ works: Task | Task[]
|
|
|
+): Promise<Task[]> => {
|
|
|
+ const workArray = Array.isArray(works) ? works : [works];
|
|
|
+
|
|
|
+ const preparedWorks = await Promise.all(
|
|
|
+ workArray.map(async (work) => {
|
|
|
+ // 调用 API 获取详细数据
|
|
|
+ const taskDetails = await fetchTaskDetails(work.StudyID);
|
|
|
+
|
|
|
+ // 转换数据格式: Series → Views
|
|
|
+ return {
|
|
|
+ ...work,
|
|
|
+ Views: taskDetails.series.flatMap<dview>((series: Series) =>
|
|
|
+ series.images.map<dview>(
|
|
|
+ (image: XImage) =>
|
|
|
+ ({
|
|
|
+ view_id: image.view_id,
|
|
|
+ series_instance_uid: series.series_instance_uid,
|
|
|
+ study_instance_uid: taskDetails.study_instance_uid,
|
|
|
+ study_id: taskDetails.study_id,
|
|
|
+ procedure_id: series.procedure_id,
|
|
|
+ view_description: image.view_description,
|
|
|
+ view_type: '',
|
|
|
+ PrimarySopUID: image.sop_instance_uid,
|
|
|
+ expose_status: image.expose_status, // 关键字段
|
|
|
+ judged_status: image.judged_status,
|
|
|
+ image_file_path: image.image_file_path,
|
|
|
+ image_file: image.image_file_path,
|
|
|
+ thumbnail_file: image.thumbnail_file || '',
|
|
|
+ }) satisfies dview
|
|
|
+ )
|
|
|
+ ),
|
|
|
+ } as Task;
|
|
|
+ })
|
|
|
+ );
|
|
|
+
|
|
|
+ return preparedWorks;
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3. 曝光状态判断(关键逻辑)
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 判断条件
|
|
|
+const allExposed =
|
|
|
+ updatedTask.Views.length > 0 && // 1. 确保有体位数据
|
|
|
+ updatedTask.Views.every((view) => view.expose_status === 'Exposed'); // 2. 所有体位已曝光
|
|
|
+
|
|
|
+// 决策分支
|
|
|
+if (allExposed) {
|
|
|
+ // 路径A: 进入处理界面
|
|
|
+ const bodyPositions = await transformWorksToBodyPositions([updatedTask]);
|
|
|
+ dispatch(setBodyPositions(bodyPositions));
|
|
|
+ dispatch(setBusinessFlow('process'));
|
|
|
+} else {
|
|
|
+ // 路径B: 进入检查界面
|
|
|
+ dispatch(setBusinessFlow('exam'));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🧪 测试验证要点
|
|
|
+
|
|
|
+### Redux 状态验证
|
|
|
+
|
|
|
+#### 验证 examWorksCache
|
|
|
+
|
|
|
+```typescript
|
|
|
+cy.window()
|
|
|
+ .its('store')
|
|
|
+ .invoke('getState')
|
|
|
+ .its('examWorksCache')
|
|
|
+ .its('works')
|
|
|
+ .should('have.length', 1)
|
|
|
+ .its(0)
|
|
|
+ .should((task) => {
|
|
|
+ expect(task).to.have.property('StudyID');
|
|
|
+ expect(task.Views).to.be.an('array');
|
|
|
+ expect(task.Views.length).to.be.greaterThan(0);
|
|
|
+ });
|
|
|
+```
|
|
|
+
|
|
|
+#### 验证 businessFlow(检查界面)
|
|
|
+
|
|
|
+```typescript
|
|
|
+cy.window()
|
|
|
+ .its('store')
|
|
|
+ .invoke('getState')
|
|
|
+ .its('businessFlow')
|
|
|
+ .its('currentKey')
|
|
|
+ .should('eq', 'exam');
|
|
|
+```
|
|
|
+
|
|
|
+#### 验证 businessFlow(处理界面)
|
|
|
+
|
|
|
+```typescript
|
|
|
+cy.window()
|
|
|
+ .its('store')
|
|
|
+ .invoke('getState')
|
|
|
+ .its('businessFlow')
|
|
|
+ .its('currentKey')
|
|
|
+ .should('eq', 'process');
|
|
|
+```
|
|
|
+
|
|
|
+#### 验证 bodyPositionList(处理界面)
|
|
|
+
|
|
|
+```typescript
|
|
|
+cy.window()
|
|
|
+ .its('store')
|
|
|
+ .invoke('getState')
|
|
|
+ .its('bodyPositionList')
|
|
|
+ .its('positions')
|
|
|
+ .should('have.length.greaterThan', 0)
|
|
|
+ .each((bp) => {
|
|
|
+ expect(bp).to.have.property('dview');
|
|
|
+ expect(bp.dview).to.have.property('expose_status', 'Exposed');
|
|
|
+ });
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### API Mock 验证
|
|
|
+
|
|
|
+#### Mock 未曝光任务详情
|
|
|
+
|
|
|
+```typescript
|
|
|
+cy.intercept('GET', '/dr/api/v1/auth/study/*', {
|
|
|
+ statusCode: 200,
|
|
|
+ body: {
|
|
|
+ study_id: 'TEST001',
|
|
|
+ study_instance_uid: '1.2.3.4.5',
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ series_instance_uid: '1.2.3.4.5.1',
|
|
|
+ images: [
|
|
|
+ {
|
|
|
+ sop_instance_uid: '1.2.3.4.5.1.1',
|
|
|
+ view_id: 'VIEW001',
|
|
|
+ expose_status: 'Unexposed', // 关键: 未曝光
|
|
|
+ // ... 其他字段
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}).as('getStudyDetailsUnexposed');
|
|
|
+```
|
|
|
+
|
|
|
+#### Mock 已曝光任务详情
|
|
|
+
|
|
|
+```typescript
|
|
|
+cy.intercept('GET', '/dr/api/v1/auth/study/*', {
|
|
|
+ statusCode: 200,
|
|
|
+ body: {
|
|
|
+ study_id: 'TEST002',
|
|
|
+ study_instance_uid: '1.2.3.4.6',
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ series_instance_uid: '1.2.3.4.6.1',
|
|
|
+ images: [
|
|
|
+ {
|
|
|
+ sop_instance_uid: '1.2.3.4.6.1.1',
|
|
|
+ view_id: 'VIEW002',
|
|
|
+ expose_status: 'Exposed', // 关键: 已曝光
|
|
|
+ image_file_path: '/path/to/image.dcm',
|
|
|
+ thumbnail_file: '/path/to/thumb.jpg',
|
|
|
+ // ... 其他字段
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}).as('getStudyDetailsExposed');
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🐛 边界情况处理
|
|
|
+
|
|
|
+### 1. Views 为空数组
|
|
|
+
|
|
|
+**情况**: `updatedTask.Views.length === 0`
|
|
|
+
|
|
|
+**判断结果**: `allExposed = false`(因为前置条件失败)
|
|
|
+
|
|
|
+**处理**: 进入检查界面(Exam)
|
|
|
+
|
|
|
+**原因**: 没有体位数据,需要在检查界面配置
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2. 混合曝光状态
|
|
|
+
|
|
|
+**情况**: 部分体位 `expose_status === 'Exposed'`,部分 `'Unexposed'`
|
|
|
+
|
|
|
+**判断结果**: `allExposed = false`(`every()` 条件不满足)
|
|
|
+
|
|
|
+**处理**: 进入检查界面(Exam)
|
|
|
+
|
|
|
+**原因**: 存在未完成的曝光任务,需要继续检查
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3. expose_status 其他值
|
|
|
+
|
|
|
+**可能值**:
|
|
|
+- `'Exposed'` - 已曝光(已拍摄)
|
|
|
+- `'Unexposed'` - 未曝光(待拍摄)
|
|
|
+- 可能存在其他状态(如 `'Deleted'`, `'Archived'` 等)
|
|
|
+
|
|
|
+**处理**:
|
|
|
+- 只有完全等于 `'Exposed'` 才算已曝光
|
|
|
+- 其他任何值都视为未曝光,进入检查界面
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 4. API 调用失败
|
|
|
+
|
|
|
+**情况**: `fetchTaskDetails()` 抛出错误
|
|
|
+
|
|
|
+**处理**:
|
|
|
+```typescript
|
|
|
+try {
|
|
|
+ // ... 业务逻辑
|
|
|
+} catch (error) {
|
|
|
+ console.error('Error in worklistToExam:', error);
|
|
|
+ throw error; // 向上层抛出,由调用方处理
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**说明**: 错误会冒泡到 UI 层,通常会显示错误提示
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📝 设计模式和架构特点
|
|
|
+
|
|
|
+### 1. 关注点分离
|
|
|
+
|
|
|
+| 层次 | 职责 | 示例文件 |
|
|
|
+|------|------|---------|
|
|
|
+| **UI 层** | 用户交互、事件触发 | `worklist.tsx`, `HistoryList.tsx` |
|
|
|
+| **Domain 层** | 业务逻辑、数据转换 | `worklistToExam.ts` |
|
|
|
+| **API 层** | 后端通信 | `workActions.ts` |
|
|
|
+| **State 层** | 状态管理 | `examWorksCacheSlice.ts`, `businessFlowSlice.ts` |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2. 单一职责原则
|
|
|
+
|
|
|
+- `prepareWorksForExam()` - 仅负责数据准备和转换
|
|
|
+- `worklistToExam()` - 负责业务逻辑和流程控制
|
|
|
+- `transformWorksToBodyPositions()` - 仅负责体位列表转换
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3. 异步流程处理
|
|
|
+
|
|
|
+所有 API 调用和数据转换都使用 `async/await` 模式:
|
|
|
+
|
|
|
+```typescript
|
|
|
+const [updatedTask] = await prepareWorksForExam(task);
|
|
|
+const bodyPositions = await transformWorksToBodyPositions([updatedTask]);
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 4. 数据不可变性
|
|
|
+
|
|
|
+使用 Redux Toolkit 的 Immer,确保状态更新的不可变性:
|
|
|
+
|
|
|
+```typescript
|
|
|
+dispatch(clearWorks()); // 不直接修改 state,而是 dispatch action
|
|
|
+dispatch(addWork(updatedTask));
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🚀 性能优化考虑
|
|
|
+
|
|
|
+### 1. 并行数据获取
|
|
|
+
|
|
|
+`prepareWorksForExam` 使用 `Promise.all` 并行处理多个任务:
|
|
|
+
|
|
|
+```typescript
|
|
|
+const preparedWorks = await Promise.all(
|
|
|
+ workArray.map(async (work) => {
|
|
|
+ const taskDetails = await fetchTaskDetails(work.StudyID);
|
|
|
+ return { ...work, Views: ... };
|
|
|
+ })
|
|
|
+);
|
|
|
+```
|
|
|
+
|
|
|
+**优势**: 多个任务同时请求,减少总等待时间
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2. 条件性体位转换
|
|
|
+
|
|
|
+只有在进入处理界面时才执行体位转换:
|
|
|
+
|
|
|
+```typescript
|
|
|
+if (allExposed) {
|
|
|
+ const bodyPositions = await transformWorksToBodyPositions([updatedTask]);
|
|
|
+ dispatch(setBodyPositions(bodyPositions));
|
|
|
+ dispatch(setBusinessFlow('process'));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**优势**: 避免不必要的数据转换计算
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3. 缓存清理
|
|
|
+
|
|
|
+每次进入前清空旧缓存,避免数据冲突:
|
|
|
+
|
|
|
+```typescript
|
|
|
+dispatch(clearWorks()); // 清理旧数据
|
|
|
+dispatch(addWork(updatedTask)); // 添加新数据
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📚 相关文档索引
|
|
|
+
|
|
|
+### 实现文档
|
|
|
+
|
|
|
+- [体位全曝光-worklist-双击-进入检查.md](./实现/体位全曝光-worklist-双击-进入检查.md)
|
|
|
+- [exam体位列表-双击已曝光体位进入处理.md](./实现/exam体位列表-双击已曝光体位进入处理.md)
|
|
|
+
|
|
|
+### 测试文档
|
|
|
+
|
|
|
+- [进入检查功能测试方案.md](./测试/进入检查功能测试方案.md)
|
|
|
+
|
|
|
+### 代码文件
|
|
|
+
|
|
|
+- [worklistToExam.ts](../src/domain/patient/worklistToExam.ts) - 核心业务逻辑
|
|
|
+- [worklist.tsx](../src/pages/patient/worklist.tsx) - Worklist 页面
|
|
|
+- [HistoryList.tsx](../src/pages/patient/HistoryList.tsx) - History 页面
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## ✅ 总结
|
|
|
+
|
|
|
+### 核心要点
|
|
|
+
|
|
|
+1. **双击触发**: 从 Worklist 或 History 双击任务行触发流程
|
|
|
+2. **数据准备**: 通过 API 获取完整的 Study 数据,转换为 Views
|
|
|
+3. **状态判断**: 根据所有体位的 `expose_status` 判断是否全部曝光
|
|
|
+4. **流程分支**:
|
|
|
+ - 全部已曝光 → 处理界面 (Process)
|
|
|
+ - 存在未曝光 → 检查界面 (Exam)
|
|
|
+5. **状态管理**: 通过 Redux 管理任务缓存、体位列表和业务流程
|
|
|
+
|
|
|
+### 关键判断逻辑
|
|
|
+
|
|
|
+```typescript
|
|
|
+const allExposed =
|
|
|
+ updatedTask.Views.length > 0 &&
|
|
|
+ updatedTask.Views.every((view) => view.expose_status === 'Exposed');
|
|
|
+```
|
|
|
+
|
|
|
+### 测试覆盖
|
|
|
+
|
|
|
+- ✅ Worklist → Exam (未曝光)
|
|
|
+- ✅ Worklist → Process (已曝光)
|
|
|
+- ✅ History → Exam (未曝光)
|
|
|
+- ✅ History → Process (已曝光)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+**文档版本**: v1.0
|
|
|
+**最后更新**: 2025-12-22
|
|
|
+**维护者**: Development Team
|