文档名称: 从任务清单进入检查的流程总结
创建日期: 2025-12-22
文档目的: 全面总结从 Worklist/History 任务清单双击进入检查或处理界面的完整业务流程
涵盖范围:
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
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
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
入口点:
触发函数:
handleRowDoubleClick(record: Task) → worklistToExam(task)输入: Task 对象(可能包含不完整的 Views 数据)
处理流程:
fetchTaskDetails(StudyID) 获取完整的 Study 数据series 和 images 数据flatMap 将 Series 和 Images 转换为 dview[] 格式输出: Task 对象(包含完整的 Views 数据)
数据映射关系:
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 | 缩略图路径 |
判断逻辑:
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' - 未曝光(待拍摄)步骤顺序:
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,否则处理界面无法正常显示
步骤顺序:
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,因为会在界面内动态生成和管理
触发机制: 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 |
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: 导航到检查界面
关键验证点:
expose_status: 'Unexposed' 的 imagesbusinessFlow.currentKey === 'exam'examWorksCache.works.length === 1sequenceDiagram
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: 导航到处理界面
关键验证点:
expose_status === 'Exposed'businessFlow.currentKey === 'process'bodyPositionList.positions.length > 0流程: 与场景1相同,仅入口点不同(从 History 页面触发)
流程: 与场景2相同,仅入口点不同(从 History 页面触发)
| 文件路径 | 职责 | 关键函数 |
|---|---|---|
src/domain/patient/worklistToExam.ts |
核心业务逻辑 | worklistToExam(), prepareWorksForExam(), worklistToProcess() |
| 文件路径 | 职责 | 关键函数/组件 |
|---|---|---|
src/pages/patient/worklist.tsx |
Worklist 页面 | handleRowDoubleClick() |
src/pages/patient/HistoryList.tsx |
History 页面 | handleRowDoubleClick() |
src/pages/patient/components/WorklistTable.tsx |
表格组件 | onDoubleClick 事件处理 |
| 文件路径 | Slice 名称 | 关键 Actions |
|---|---|---|
src/states/exam/examWorksCacheSlice.ts |
examWorksCache |
clearWorks(), addWork() |
src/states/exam/bodyPositionListSlice.ts |
bodyPositionList |
setBodyPositions(), transformWorksToBodyPositions() |
src/states/BusinessFlowSlice.ts |
businessFlow |
setBusinessFlow() |
| 文件路径 | 职责 | 关键函数 |
|---|---|---|
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 接口 |
// 文件: 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;
}
};
// 文件: 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;
};
// 判断条件
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'));
}
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);
});
cy.window()
.its('store')
.invoke('getState')
.its('businessFlow')
.its('currentKey')
.should('eq', 'exam');
cy.window()
.its('store')
.invoke('getState')
.its('businessFlow')
.its('currentKey')
.should('eq', 'process');
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');
});
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');
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');
情况: updatedTask.Views.length === 0
判断结果: allExposed = false(因为前置条件失败)
处理: 进入检查界面(Exam)
原因: 没有体位数据,需要在检查界面配置
情况: 部分体位 expose_status === 'Exposed',部分 'Unexposed'
判断结果: allExposed = false(every() 条件不满足)
处理: 进入检查界面(Exam)
原因: 存在未完成的曝光任务,需要继续检查
可能值:
'Exposed' - 已曝光(已拍摄)'Unexposed' - 未曝光(待拍摄)'Deleted', 'Archived' 等)处理:
'Exposed' 才算已曝光情况: fetchTaskDetails() 抛出错误
处理:
try {
// ... 业务逻辑
} catch (error) {
console.error('Error in worklistToExam:', error);
throw error; // 向上层抛出,由调用方处理
}
说明: 错误会冒泡到 UI 层,通常会显示错误提示
| 层次 | 职责 | 示例文件 |
|---|---|---|
| UI 层 | 用户交互、事件触发 | worklist.tsx, HistoryList.tsx |
| Domain 层 | 业务逻辑、数据转换 | worklistToExam.ts |
| API 层 | 后端通信 | workActions.ts |
| State 层 | 状态管理 | examWorksCacheSlice.ts, businessFlowSlice.ts |
prepareWorksForExam() - 仅负责数据准备和转换worklistToExam() - 负责业务逻辑和流程控制transformWorksToBodyPositions() - 仅负责体位列表转换所有 API 调用和数据转换都使用 async/await 模式:
const [updatedTask] = await prepareWorksForExam(task);
const bodyPositions = await transformWorksToBodyPositions([updatedTask]);
使用 Redux Toolkit 的 Immer,确保状态更新的不可变性:
dispatch(clearWorks()); // 不直接修改 state,而是 dispatch action
dispatch(addWork(updatedTask));
prepareWorksForExam 使用 Promise.all 并行处理多个任务:
const preparedWorks = await Promise.all(
workArray.map(async (work) => {
const taskDetails = await fetchTaskDetails(work.StudyID);
return { ...work, Views: ... };
})
);
优势: 多个任务同时请求,减少总等待时间
只有在进入处理界面时才执行体位转换:
if (allExposed) {
const bodyPositions = await transformWorksToBodyPositions([updatedTask]);
dispatch(setBodyPositions(bodyPositions));
dispatch(setBusinessFlow('process'));
}
优势: 避免不必要的数据转换计算
每次进入前清空旧缓存,避免数据冲突:
dispatch(clearWorks()); // 清理旧数据
dispatch(addWork(updatedTask)); // 添加新数据
expose_status 判断是否全部曝光const allExposed =
updatedTask.Views.length > 0 &&
updatedTask.Views.every((view) => view.expose_status === 'Exposed');
文档版本: v1.0
最后更新: 2025-12-22
维护者: Development Team