|
@@ -0,0 +1,913 @@
|
|
|
+# Exam 体位列表 - 单击已曝光体位优化实现文档
|
|
|
+
|
|
|
+## 📋 功能概述
|
|
|
+
|
|
|
+**功能名称**: Exam 页面体位列表单击已曝光体位行为优化
|
|
|
+
|
|
|
+**实现状态**: 🔄 **待实现**
|
|
|
+
|
|
|
+**核心需求**: 优化 Exam 页面体位列表的交互行为,区分已曝光和未曝光体位的单击响应:
|
|
|
+
|
|
|
+### 在检查(exam)模式
|
|
|
+- **单击未曝光体位**:✅ 保持现有逻辑(选中体位、同步设备、使能曝光)
|
|
|
+- **单击已曝光体位**:⚠️ 需要优化
|
|
|
+ - **不下发设备 action**(不调用 `changeBodyPosition`、不调用 `setExpEnable`)
|
|
|
+ - 在 `BodyPositionDetail` 中显示**已曝光的缩略图**(而非示意图)
|
|
|
+
|
|
|
+### 在处理(process)模式
|
|
|
+- **单击未曝光体位**:✅ 保持现有逻辑(切换回 exam 模式)
|
|
|
+- **单击已曝光体位**:✅ 保持现有逻辑(选中体位)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🎯 涉及的参与者分析
|
|
|
+
|
|
|
+### 1️⃣ 组件层
|
|
|
+
|
|
|
+#### BodyPositionList.tsx
|
|
|
+**文件路径**: `src/pages/exam/components/BodyPositionList.tsx`
|
|
|
+
|
|
|
+| 元素 | 类型 | 职责 | 当前行为 |
|
|
|
+|------|------|------|---------|
|
|
|
+| BodyPositionList | React Component | 渲染体位列表,显示体位图像(示意图或缩略图) | 已实现 |
|
|
|
+| handleImageClick | Method | 处理体位图像点击事件 | 调用 `manualSelectBodyPositionWithFlowSwitch` |
|
|
|
+| ImageViewer | Component | 显示单个体位图像 | 根据 `expose_status` 选择图像源 |
|
|
|
+
|
|
|
+**关键代码**:
|
|
|
+```typescript
|
|
|
+const handleImageClick = async (bodyPosition: ExtendedBodyPosition): Promise<void> => {
|
|
|
+ await manualSelectBodyPositionWithFlowSwitch(
|
|
|
+ bodyPosition,
|
|
|
+ dispatch,
|
|
|
+ currentKey
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+// 图像显示逻辑
|
|
|
+<ImageViewer
|
|
|
+ src={
|
|
|
+ bodyPosition.dview.expose_status === 'Exposed'
|
|
|
+ ? getExposedImageUrl(bodyPosition.sop_instance_uid) // 已曝光:缩略图
|
|
|
+ : getViewIconUrl(bodyPosition.view_icon_name) // 未曝光:示意图
|
|
|
+ }
|
|
|
+ onClick={() => handleImageClick(bodyPosition)}
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+#### BodyPositionDetail.tsx
|
|
|
+**文件路径**: `src/pages/exam/components/BodyPositionDetail.tsx`
|
|
|
+
|
|
|
+| 元素 | 类型 | 职责 | 需要修改 |
|
|
|
+|------|------|------|---------|
|
|
|
+| BodyPositionDetail | React Component | 显示选中体位的详细信息 | ✅ 需要修改 |
|
|
|
+| 体位图像显示区域 | UI Element | 第三行,显示体位大图 | ✅ 需要根据曝光状态切换图像源 |
|
|
|
+
|
|
|
+**当前显示逻辑**(只显示示意图):
|
|
|
+```typescript
|
|
|
+backgroundImage: `url(${getViewIconUrl(bodyPositionDetail.body_position_image)})`
|
|
|
+```
|
|
|
+
|
|
|
+**需要改为**(根据曝光状态选择):
|
|
|
+```typescript
|
|
|
+const imageUrl = bodyPositionDetail.expose_status === 'Exposed'
|
|
|
+ ? getExposedImageUrl(bodyPositionDetail.sop_instance_uid) // 已曝光:缩略图
|
|
|
+ : getViewIconUrl(bodyPositionDetail.body_position_image); // 未曝光:示意图
|
|
|
+
|
|
|
+backgroundImage: `url(${imageUrl})`
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2️⃣ 业务逻辑层
|
|
|
+
|
|
|
+#### bodyPositionSelection.ts
|
|
|
+**文件路径**: `src/domain/exam/bodyPositionSelection.ts`
|
|
|
+
|
|
|
+| 函数 | 职责 | 需要修改 |
|
|
|
+|------|------|---------|
|
|
|
+| selectBodyPositionWithFullLogic | 核心选中逻辑,处理状态更新和设备同步 | ✅ 需要修改 |
|
|
|
+| manualSelectBodyPositionWithFlowSwitch | 带流程切换的手动选中(用户点击时调用) | ✅ 已实现自动流程切换 |
|
|
|
+| autoSelectFirstBodyPosition | 自动选中第一个体位 | ✅ 无需修改 |
|
|
|
+
|
|
|
+**当前逻辑**(exam 模式下无条件同步设备):
|
|
|
+```typescript
|
|
|
+// 3. 如果在exam模式,同步设备到对应体位
|
|
|
+if (currentKey === 'exam') {
|
|
|
+ await changeBodyPosition(bodyPosition.sop_instance_uid);
|
|
|
+ await setExpEnable();
|
|
|
+ // ...
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**需要改为**(仅未曝光时同步设备):
|
|
|
+```typescript
|
|
|
+// 3. 如果在exam模式,根据曝光状态决定是否同步设备
|
|
|
+if (currentKey === 'exam') {
|
|
|
+ if (bodyPosition.dview.expose_status === 'Unexposed') {
|
|
|
+ // 未曝光:同步设备,使能曝光
|
|
|
+ await changeBodyPosition(bodyPosition.sop_instance_uid);
|
|
|
+ await setExpEnable();
|
|
|
+ console.log(`[bodyPositionSelection] Device synced for unexposed position`);
|
|
|
+ } else {
|
|
|
+ // 已曝光:跳过设备同步,仅更新显示
|
|
|
+ console.log(`[bodyPositionSelection] Exposed position, skip device sync`);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3️⃣ 状态管理层
|
|
|
+
|
|
|
+#### bodyPositionDetailSlice.ts
|
|
|
+**文件路径**: `src/states/exam/bodyPositionDetailSlice.ts`
|
|
|
+
|
|
|
+| 元素 | 类型 | 职责 | 需要修改 |
|
|
|
+|------|------|------|---------|
|
|
|
+| BodyPositionDetailState | Interface | 体位详情状态定义 | ✅ 需要添加字段 |
|
|
|
+| setBodyPositionDetail | Reducer | 设置体位详情 | ✅ 需要更新字段 |
|
|
|
+| extraReducers | Listener | 监听 `setSelectedBodyPosition`,自动更新详情 | ✅ 需要更新字段 |
|
|
|
+
|
|
|
+**需要添加的字段**:
|
|
|
+```typescript
|
|
|
+interface BodyPositionDetailState {
|
|
|
+ // ... 现有字段
|
|
|
+ expose_status: string; // 🆕 'Exposed' | 'Unexposed'
|
|
|
+ sop_instance_uid: string; // 🆕 用于获取缩略图 URL
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**初始状态**:
|
|
|
+```typescript
|
|
|
+const initialState: BodyPositionDetailState = {
|
|
|
+ // ... 现有字段
|
|
|
+ expose_status: 'Unexposed', // 🆕 默认未曝光
|
|
|
+ sop_instance_uid: '', // 🆕 默认空
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+**Reducer 更新**:
|
|
|
+```typescript
|
|
|
+builder.addCase(setSelectedBodyPosition, (state, action) => {
|
|
|
+ const selectedBodyPosition = action.payload;
|
|
|
+ if (selectedBodyPosition) {
|
|
|
+ return {
|
|
|
+ ...state,
|
|
|
+ // ... 现有字段
|
|
|
+ expose_status: selectedBodyPosition.dview.expose_status, // 🆕
|
|
|
+ sop_instance_uid: selectedBodyPosition.sop_instance_uid, // 🆕
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return state;
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 4️⃣ API 层
|
|
|
+
|
|
|
+#### bodyPosition.ts
|
|
|
+**文件路径**: `src/API/bodyPosition.ts`
|
|
|
+
|
|
|
+| 函数 | 职责 | 状态 |
|
|
|
+|------|------|------|
|
|
|
+| getViewIconUrl | 获取体位示意图 URL | ✅ 已实现 |
|
|
|
+| getExposedImageUrl | 获取已曝光图像的缩略图 URL | ✅ 已实现 |
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 示意图 URL(用于未曝光体位)
|
|
|
+export function getViewIconUrl(viewIconName: string): string {
|
|
|
+ return `${API_BASE_URL}pub${viewIconName}`;
|
|
|
+}
|
|
|
+
|
|
|
+// 缩略图 URL(用于已曝光体位)
|
|
|
+export function getExposedImageUrl(sopInstanceUid: string): string {
|
|
|
+ return `${API_BASE_URL}pub/thumbnail/${sopInstanceUid}.webp`;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🔄 数据流分析
|
|
|
+
|
|
|
+### 场景1: 单击未曝光体位(exam 模式)
|
|
|
+
|
|
|
+```mermaid
|
|
|
+sequenceDiagram
|
|
|
+ actor User as 👤 用户
|
|
|
+ participant BPL as BodyPositionList
|
|
|
+ participant BS as bodyPositionSelection
|
|
|
+ participant API as Device API
|
|
|
+ participant Redux as Redux Store
|
|
|
+ participant BPD as BodyPositionDetail
|
|
|
+
|
|
|
+ User->>BPL: 单击未曝光体位
|
|
|
+ BPL->>BS: manualSelectBodyPositionWithFlowSwitch(bodyPosition)
|
|
|
+ BS->>BS: 检查 currentKey === 'exam'
|
|
|
+ BS->>BS: 检查 expose_status === 'Unexposed' ✅
|
|
|
+
|
|
|
+ BS->>API: changeBodyPosition(sop_instance_uid)
|
|
|
+ Note over API: 同步设备到该体位
|
|
|
+ API-->>BS: 成功
|
|
|
+
|
|
|
+ BS->>API: setExpEnable()
|
|
|
+ Note over API: 使能曝光
|
|
|
+ API-->>BS: 成功
|
|
|
+
|
|
|
+ BS->>Redux: setSelectedBodyPosition(bodyPosition)
|
|
|
+ Redux->>Redux: 自动触发 bodyPositionDetail 更新
|
|
|
+ Redux-->>BPD: expose_status = 'Unexposed'
|
|
|
+
|
|
|
+ BPD->>BPD: imageUrl = getViewIconUrl(body_position_image)
|
|
|
+ BPD-->>User: 显示示意图 🖼️
|
|
|
+```
|
|
|
+
|
|
|
+**关键步骤**:
|
|
|
+1. ✅ 调用 `changeBodyPosition` - 同步设备
|
|
|
+2. ✅ 调用 `setExpEnable` - 使能曝光
|
|
|
+3. ✅ 更新 Redux 状态
|
|
|
+4. ✅ BodyPositionDetail 显示**示意图**
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 场景2: 单击已曝光体位(exam 模式)⚠️ **核心修改**
|
|
|
+
|
|
|
+```mermaid
|
|
|
+sequenceDiagram
|
|
|
+ actor User as 👤 用户
|
|
|
+ participant BPL as BodyPositionList
|
|
|
+ participant BS as bodyPositionSelection
|
|
|
+ participant Redux as Redux Store
|
|
|
+ participant BPD as BodyPositionDetail
|
|
|
+
|
|
|
+ User->>BPL: 单击已曝光体位
|
|
|
+ BPL->>BS: manualSelectBodyPositionWithFlowSwitch(bodyPosition)
|
|
|
+ BS->>BS: 检查 currentKey === 'exam'
|
|
|
+ BS->>BS: 检查 expose_status === 'Exposed' ✅
|
|
|
+
|
|
|
+ Note over BS: ❌ 跳过 changeBodyPosition
|
|
|
+ Note over BS: ❌ 跳过 setExpEnable
|
|
|
+ Note over BS: 只更新显示状态
|
|
|
+
|
|
|
+ BS->>Redux: setSelectedBodyPosition(bodyPosition)
|
|
|
+ Redux->>Redux: 自动触发 bodyPositionDetail 更新
|
|
|
+ Redux-->>BPD: expose_status = 'Exposed'<br/>sop_instance_uid = '...'
|
|
|
+
|
|
|
+ BPD->>BPD: imageUrl = getExposedImageUrl(sop_instance_uid)
|
|
|
+ BPD-->>User: 显示缩略图 📷
|
|
|
+```
|
|
|
+
|
|
|
+**关键差异**:
|
|
|
+1. ❌ **不调用** `changeBodyPosition` - 不同步设备
|
|
|
+2. ❌ **不调用** `setExpEnable` - 不使能曝光
|
|
|
+3. ✅ 更新 Redux 状态(包含 `expose_status` 和 `sop_instance_uid`)
|
|
|
+4. ✅ BodyPositionDetail 显示**缩略图**
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 场景3: 单击未曝光体位(process 模式)
|
|
|
+
|
|
|
+```mermaid
|
|
|
+sequenceDiagram
|
|
|
+ actor User as 👤 用户
|
|
|
+ participant BPL as BodyPositionList
|
|
|
+ participant BS as bodyPositionSelection
|
|
|
+ participant Redux as Redux Store
|
|
|
+
|
|
|
+ User->>BPL: 单击未曝光体位
|
|
|
+ BPL->>BS: manualSelectBodyPositionWithFlowSwitch(bodyPosition)
|
|
|
+ BS->>BS: 检查 currentKey === 'process'
|
|
|
+ BS->>BS: 检查 expose_status === 'Unexposed' ✅
|
|
|
+
|
|
|
+ Note over BS: 需要切换到 exam 模式
|
|
|
+ BS->>Redux: setBusinessFlow('exam')
|
|
|
+ Note over Redux: currentKey: 'process' → 'exam'
|
|
|
+
|
|
|
+ BS->>BS: 等待流程切换完成
|
|
|
+ BS->>BS: selectBodyPositionWithFullLogic<br/>(targetFlow = 'exam')
|
|
|
+
|
|
|
+ Note over BS: 现在在 exam 模式,未曝光体位
|
|
|
+ Note over BS: 执行场景1的逻辑
|
|
|
+```
|
|
|
+
|
|
|
+**关键行为**: ✅ 保持现有逻辑(自动切换到 exam 并同步设备)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📊 状态对比表
|
|
|
+
|
|
|
+| 场景 | 模式 | 体位状态 | changeBodyPosition | setExpEnable | 详情显示 |
|
|
|
+|------|------|---------|-------------------|--------------|---------|
|
|
|
+| 1 | exam | Unexposed | ✅ 调用 | ✅ 调用 | 示意图 🖼️ |
|
|
|
+| 2 | exam | Exposed | ❌ 跳过 | ❌ 跳过 | **缩略图 📷** |
|
|
|
+| 3 | process | Unexposed | ✅ 调用(切换后) | ✅ 调用(切换后) | 示意图 🖼️ |
|
|
|
+| 4 | process | Exposed | ❌ 跳过 | ❌ 跳过 | **缩略图 📷** |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 💻 实现方案
|
|
|
+
|
|
|
+### 修改清单
|
|
|
+
|
|
|
+| 文件 | 修改类型 | 行号/位置 | 具体内容 |
|
|
|
+|------|---------|----------|---------|
|
|
|
+| `bodyPositionSelection.ts` | 🔧 修改逻辑 | 第47-60行 | 在 `selectBodyPositionWithFullLogic` 添加曝光状态判断 |
|
|
|
+| `bodyPositionDetailSlice.ts` | ➕ 添加字段 | Interface 定义 | 添加 `expose_status` 和 `sop_instance_uid` 字段 |
|
|
|
+| `bodyPositionDetailSlice.ts` | 🔧 修改 Reducer | extraReducers | 更新字段映射逻辑 |
|
|
|
+| `BodyPositionDetail.tsx` | 🔧 修改显示 | 第三行图像区域 | 根据 `expose_status` 动态选择图像源 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 1️⃣ 修改 `bodyPositionSelection.ts`
|
|
|
+
|
|
|
+**文件**: `src/domain/exam/bodyPositionSelection.ts`
|
|
|
+
|
|
|
+```typescript
|
|
|
+export const selectBodyPositionWithFullLogic = async (
|
|
|
+ bodyPosition: ExtendedBodyPosition,
|
|
|
+ dispatch: AppDispatch,
|
|
|
+ currentKey: string,
|
|
|
+ showMessage = false
|
|
|
+): Promise<void> => {
|
|
|
+ try {
|
|
|
+ console.log(
|
|
|
+ `[bodyPositionSelection] Selecting body position: ${bodyPosition.view_name}`
|
|
|
+ );
|
|
|
+
|
|
|
+ // 1. 更新选中状态
|
|
|
+ dispatch(setSelectedBodyPosition(bodyPosition));
|
|
|
+
|
|
|
+ // 2. 设置详情显示区域的数据
|
|
|
+ dispatch(
|
|
|
+ setBodyPositionDetail({
|
|
|
+ view_name: bodyPosition.view_name,
|
|
|
+ view_description: bodyPosition.view_description,
|
|
|
+ view_icon_name: bodyPosition.view_icon_name,
|
|
|
+ patient_name: bodyPosition.patient_name,
|
|
|
+ patient_id: bodyPosition.patient_id,
|
|
|
+ registration_number: bodyPosition.registration_number,
|
|
|
+ study_description: bodyPosition.study_description,
|
|
|
+ body_position_image: bodyPosition.view_big_icon_name,
|
|
|
+ collimator_length: bodyPosition.collimator_length,
|
|
|
+ collimator_width: bodyPosition.collimator_width,
|
|
|
+ sid: bodyPosition.sid,
|
|
|
+ })
|
|
|
+ );
|
|
|
+
|
|
|
+ // 3. 🆕 如果在exam模式,根据曝光状态决定是否同步设备
|
|
|
+ if (currentKey === 'exam') {
|
|
|
+ if (bodyPosition.dview.expose_status === 'Unexposed') {
|
|
|
+ // 未曝光体位:同步设备,使能曝光
|
|
|
+ await changeBodyPosition(bodyPosition.sop_instance_uid);
|
|
|
+ await setExpEnable();
|
|
|
+
|
|
|
+ const successMsg = `Body position changed successfully: ${bodyPosition.view_name}`;
|
|
|
+ console.log(`[bodyPositionSelection] ${successMsg}`);
|
|
|
+
|
|
|
+ if (showMessage) {
|
|
|
+ message.success(successMsg);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 已曝光体位:跳过设备同步,仅更新显示
|
|
|
+ console.log(
|
|
|
+ `[bodyPositionSelection] Exposed position selected, skipping device sync: ${bodyPosition.view_name}`
|
|
|
+ );
|
|
|
+
|
|
|
+ if (showMessage) {
|
|
|
+ message.info(`已选中已曝光体位: ${bodyPosition.view_name}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ console.log(
|
|
|
+ `[bodyPositionSelection] Current key is ${currentKey}, not executing changeBodyPosition.`
|
|
|
+ );
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ const errorMsg = 'Failed to change body position';
|
|
|
+ console.error(`[bodyPositionSelection] ${errorMsg}:`, error);
|
|
|
+
|
|
|
+ if (showMessage) {
|
|
|
+ message.error(errorMsg);
|
|
|
+ }
|
|
|
+
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+**关键修改**:
|
|
|
+- ✅ 添加 `if (bodyPosition.dview.expose_status === 'Unexposed')` 判断
|
|
|
+- ✅ 只有未曝光时才调用 `changeBodyPosition` 和 `setExpEnable`
|
|
|
+- ✅ 已曝光时只记录日志,可选显示提示信息
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2️⃣ 修改 `bodyPositionDetailSlice.ts`
|
|
|
+
|
|
|
+**文件**: `src/states/exam/bodyPositionDetailSlice.ts`
|
|
|
+
|
|
|
+#### Step 1: 更新 Interface
|
|
|
+
|
|
|
+```typescript
|
|
|
+interface BodyPositionDetailState {
|
|
|
+ view_name: string;
|
|
|
+ view_description: string;
|
|
|
+ view_icon_name: string;
|
|
|
+ patient_name: string;
|
|
|
+ patient_id: string;
|
|
|
+ registration_number: string;
|
|
|
+ study_description: string;
|
|
|
+ body_position_image: string;
|
|
|
+ collimator_length: string | number;
|
|
|
+ collimator_width: string | number;
|
|
|
+ sid: string;
|
|
|
+ // 🆕 添加以下字段
|
|
|
+ expose_status: string; // 'Exposed' | 'Unexposed'
|
|
|
+ sop_instance_uid: string; // 用于获取缩略图 URL
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### Step 2: 更新 initialState
|
|
|
+
|
|
|
+```typescript
|
|
|
+const initialState: BodyPositionDetailState = {
|
|
|
+ view_name: '',
|
|
|
+ view_description: '',
|
|
|
+ view_icon_name: '',
|
|
|
+ patient_name: '',
|
|
|
+ patient_id: '',
|
|
|
+ registration_number: '',
|
|
|
+ study_description: '',
|
|
|
+ body_position_image: '',
|
|
|
+ collimator_length: '',
|
|
|
+ collimator_width: '',
|
|
|
+ sid: '',
|
|
|
+ // 🆕 添加初始值
|
|
|
+ expose_status: 'Unexposed',
|
|
|
+ sop_instance_uid: '',
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+#### Step 3: 更新 extraReducers
|
|
|
+
|
|
|
+```typescript
|
|
|
+extraReducers: (builder) => {
|
|
|
+ builder.addCase(setSelectedBodyPosition, (state, action) => {
|
|
|
+ const selectedBodyPosition = action.payload;
|
|
|
+ if (selectedBodyPosition) {
|
|
|
+ return {
|
|
|
+ ...state,
|
|
|
+ view_name: selectedBodyPosition.view_name,
|
|
|
+ view_description: selectedBodyPosition.view_description,
|
|
|
+ view_icon_name: selectedBodyPosition.view_icon_name,
|
|
|
+ patient_name: selectedBodyPosition.patient_name,
|
|
|
+ patient_id: selectedBodyPosition.patient_id,
|
|
|
+ registration_number: selectedBodyPosition.registration_number,
|
|
|
+ study_description: selectedBodyPosition.study_description,
|
|
|
+ body_position_image: selectedBodyPosition.body_position_image,
|
|
|
+ collimator_length: selectedBodyPosition.collimator_length,
|
|
|
+ collimator_width: selectedBodyPosition.collimator_width,
|
|
|
+ sid: selectedBodyPosition.sid,
|
|
|
+ // 🆕 添加字段映射
|
|
|
+ expose_status: selectedBodyPosition.dview.expose_status,
|
|
|
+ sop_instance_uid: selectedBodyPosition.sop_instance_uid,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return state;
|
|
|
+ });
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3️⃣ 修改 `BodyPositionDetail.tsx`
|
|
|
+
|
|
|
+**文件**: `src/pages/exam/components/BodyPositionDetail.tsx`
|
|
|
+
|
|
|
+```typescript
|
|
|
+import React, { useRef } from 'react';
|
|
|
+import { useSelector } from 'react-redux';
|
|
|
+import { Row, Col, Typography, Image, Flex } from 'antd';
|
|
|
+import { getViewIconUrl, getExposedImageUrl } from '@/API/bodyPosition'; // 🆕 添加 import
|
|
|
+import CollimatorIcon from '../../../assets/imgs/Collimator_normal.png';
|
|
|
+import SidIcon from '../../../assets/imgs/SID.png';
|
|
|
+import { RootState } from '@/states/store';
|
|
|
+
|
|
|
+const { Title, Text } = Typography;
|
|
|
+
|
|
|
+const BodyPositionDetail: React.FC = () => {
|
|
|
+ const bodyPositionDetail = useSelector(
|
|
|
+ (state: RootState) => state.bodyPositionDetail
|
|
|
+ );
|
|
|
+ const wrapRef = useRef<HTMLDivElement>(null);
|
|
|
+
|
|
|
+ // 🆕 根据曝光状态选择图像源
|
|
|
+ const imageUrl = bodyPositionDetail.expose_status === 'Exposed'
|
|
|
+ ? getExposedImageUrl(bodyPositionDetail.sop_instance_uid) // 已曝光:缩略图
|
|
|
+ : getViewIconUrl(bodyPositionDetail.body_position_image); // 未曝光:示意图
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Flex vertical className="h-full">
|
|
|
+ {/* 第一行 : 患者姓名*/}
|
|
|
+ <div className="text-center">
|
|
|
+ <Title level={4}>{bodyPositionDetail.patient_name}</Title>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 第二行 :患者id,登记号,study描述*/}
|
|
|
+ <Row>
|
|
|
+ <Col span={8}>
|
|
|
+ <Text>Patient ID: {bodyPositionDetail.patient_id}</Text>
|
|
|
+ </Col>
|
|
|
+ <Col span={8}>
|
|
|
+ <Text>
|
|
|
+ Accession Number: {bodyPositionDetail.registration_number}
|
|
|
+ </Text>
|
|
|
+ </Col>
|
|
|
+ <Col span={8}>
|
|
|
+ <Text>Study Description: {bodyPositionDetail.study_description}</Text>
|
|
|
+ </Col>
|
|
|
+ </Row>
|
|
|
+
|
|
|
+ {/* 第三行 :体位示意图或缩略图 🆕 修改此处 */}
|
|
|
+ <Flex
|
|
|
+ style={{ minHeight: 0 }}
|
|
|
+ className="flex-grow"
|
|
|
+ ref={wrapRef}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ backgroundImage: `url(${imageUrl})`, // 🆕 使用动态 imageUrl
|
|
|
+ backgroundSize: 'contain',
|
|
|
+ backgroundPosition: 'center',
|
|
|
+ backgroundRepeat: 'no-repeat',
|
|
|
+ }}
|
|
|
+ ></div>
|
|
|
+ </Flex>
|
|
|
+
|
|
|
+ {/* 第四行 :体位描述*/}
|
|
|
+ <div className="text-center">
|
|
|
+ <Text>View Description: {bodyPositionDetail.view_description}</Text>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 第五行 :设备信息*/}
|
|
|
+ <div className="flex flex-row items-center justify-center">
|
|
|
+ <div className="text-center">
|
|
|
+ <Image src={CollimatorIcon} alt="Logo" height={'100%'} />
|
|
|
+ </div>
|
|
|
+ <div className="text-center">
|
|
|
+ <Text>Length: {bodyPositionDetail.collimator_length}</Text>
|
|
|
+ </div>
|
|
|
+ <div className="text-center ml-2">
|
|
|
+ <Text>Width: {bodyPositionDetail.collimator_width}</Text>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 第六行 */}
|
|
|
+ <Flex justify="center" align="center">
|
|
|
+ <Image src={SidIcon} alt="Logo" className="mr-2" height={'100%'} />
|
|
|
+ <Text>SID: {bodyPositionDetail.sid}</Text>
|
|
|
+ </Flex>
|
|
|
+ </Flex>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default BodyPositionDetail;
|
|
|
+```
|
|
|
+
|
|
|
+**关键修改**:
|
|
|
+- ✅ 添加 `getExposedImageUrl` import
|
|
|
+- ✅ 添加 `imageUrl` 计算逻辑(根据 `expose_status` 选择)
|
|
|
+- ✅ 使用 `imageUrl` 而非固定的 `getViewIconUrl`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🧪 测试方案
|
|
|
+
|
|
|
+### 测试环境准备
|
|
|
+
|
|
|
+1. 确保有包含已曝光和未曝光体位的检查数据
|
|
|
+2. 测试数据示例:
|
|
|
+ - Study 1: 2个体位(1个已曝光,1个未曝光)
|
|
|
+ - Study 2: 3个体位(全部已曝光)
|
|
|
+ - Study 3: 2个体位(全部未曝光)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 测试场景
|
|
|
+
|
|
|
+#### 场景1: Exam 模式 - 单击未曝光体位 ✅
|
|
|
+
|
|
|
+**前置条件**: 在 exam 模式,有未曝光体位
|
|
|
+
|
|
|
+**测试步骤**:
|
|
|
+1. 进入 exam 页面
|
|
|
+2. 单击一个未曝光体位(显示示意图的)
|
|
|
+
|
|
|
+**预期结果**:
|
|
|
+- ✅ 控制台输出设备同步日志
|
|
|
+- ✅ 设备参数更新到该体位配置
|
|
|
+- ✅ 曝光功能使能
|
|
|
+- ✅ BodyPositionDetail 显示体位**示意图**
|
|
|
+- ✅ 选中框高亮该体位
|
|
|
+
|
|
|
+**验证方法**:
|
|
|
+```javascript
|
|
|
+// Redux State 检查
|
|
|
+bodyPositionDetail.expose_status === 'Unexposed'
|
|
|
+bodyPositionDetail.body_position_image !== ''
|
|
|
+
|
|
|
+// 控制台日志
|
|
|
+'[bodyPositionSelection] Device synced for unexposed position'
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+#### 场景2: Exam 模式 - 单击已曝光体位 ⚠️ **核心场景**
|
|
|
+
|
|
|
+**前置条件**: 在 exam 模式,有已曝光体位
|
|
|
+
|
|
|
+**测试步骤**:
|
|
|
+1. 在 exam 页面
|
|
|
+2. 单击一个已曝光体位(显示缩略图的)
|
|
|
+
|
|
|
+**预期结果**:
|
|
|
+- ✅ **不调用** `changeBodyPosition`
|
|
|
+- ✅ **不调用** `setExpEnable`
|
|
|
+- ✅ 控制台输出跳过设备同步日志
|
|
|
+- ✅ BodyPositionDetail 显示**缩略图**(而非示意图)
|
|
|
+- ✅ 选中框高亮该体位
|
|
|
+- ✅ 可选:显示提示信息"已选中已曝光体位"
|
|
|
+
|
|
|
+**验证方法**:
|
|
|
+```javascript
|
|
|
+// Redux State 检查
|
|
|
+bodyPositionDetail.expose_status === 'Exposed'
|
|
|
+bodyPositionDetail.sop_instance_uid !== ''
|
|
|
+
|
|
|
+// 控制台日志
|
|
|
+'[bodyPositionSelection] Exposed position selected, skipping device sync'
|
|
|
+
|
|
|
+// 图像 URL 检查
|
|
|
+backgroundImage.includes('/pub/thumbnail/') // 缩略图路径
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+#### 场景3: Process 模式 - 单击未曝光体位 ✅
|
|
|
+
|
|
|
+**前置条件**: 在 process 模式
|
|
|
+
|
|
|
+**测试步骤**:
|
|
|
+1. 进入 process 页面
|
|
|
+2. 单击一个未曝光体位
|
|
|
+
|
|
|
+**预期结果**:
|
|
|
+- ✅ 自动切换到 exam 模式
|
|
|
+- ✅ 执行场景1的逻辑(同步设备)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+#### 场景4: Process 模式 - 单击已曝光体位 ✅
|
|
|
+
|
|
|
+**前置条件**: 在 process 模式
|
|
|
+
|
|
|
+**测试步骤**:
|
|
|
+1. 在 process 页面
|
|
|
+2. 单击一个已曝光体位
|
|
|
+
|
|
|
+**预期结果**:
|
|
|
+- ✅ 保持在 process 模式
|
|
|
+- ✅ 不同步设备
|
|
|
+- ✅ BodyPositionDetail 显示**缩略图**
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+#### 场景5: 曝光后状态更新 🔄
|
|
|
+
|
|
|
+**前置条件**: 在 exam 模式,有未曝光体位
|
|
|
+
|
|
|
+**测试步骤**:
|
|
|
+1. 选中一个未曝光体位
|
|
|
+2. 执行曝光操作
|
|
|
+3. 曝光成功后,MQTT 消息更新体位状态为 'Exposed'
|
|
|
+4. 再次单击该体位
|
|
|
+
|
|
|
+**预期结果**:
|
|
|
+- ✅ 第一次单击:同步设备,显示示意图
|
|
|
+- ✅ 曝光后:BodyPositionList 中该体位显示缩略图
|
|
|
+- ✅ 第二次单击:不同步设备,BodyPositionDetail 显示缩略图
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 测试用例总结表
|
|
|
+
|
|
|
+| 场景 | 模式 | 体位状态 | 设备同步 | 详情显示 | 优先级 |
|
|
|
+|------|------|---------|---------|---------|--------|
|
|
|
+| 1 | exam | Unexposed | ✅ 执行 | 示意图 🖼️ | P0 |
|
|
|
+| 2 | exam | Exposed | ❌ 跳过 | **缩略图 📷** | P0 |
|
|
|
+| 3 | process | Unexposed | ✅ 执行(切换后) | 示意图 🖼️ | P1 |
|
|
|
+| 4 | process | Exposed | ❌ 跳过 | **缩略图 📷** | P1 |
|
|
|
+| 5 | exam | Unexposed → Exposed | 第一次✅ / 第二次❌ | 示意图→缩略图 | P1 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🐛 边界情况分析
|
|
|
+
|
|
|
+### 1. 曝光状态字段缺失
|
|
|
+
|
|
|
+**潜在问题**: 如果 `dview.expose_status` 字段不存在或为 `undefined`
|
|
|
+
|
|
|
+**处理方案**:
|
|
|
+```typescript
|
|
|
+// 添加防护判断
|
|
|
+if (bodyPosition.dview.expose_status === 'Unexposed') {
|
|
|
+ // 同步设备
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**说明**: `undefined !== 'Unexposed'`,所以会跳过设备同步,行为安全。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2. SOP UID 为空
|
|
|
+
|
|
|
+**潜在问题**: 已曝光体位但 `sop_instance_uid` 为空字符串
|
|
|
+
|
|
|
+**影响**:
|
|
|
+- `getExposedImageUrl('')` 会返回 `/pub/thumbnail/.webp`
|
|
|
+- 导致图像加载失败
|
|
|
+
|
|
|
+**处理方案**:
|
|
|
+```typescript
|
|
|
+// 在 BodyPositionDetail.tsx 中添加防护
|
|
|
+const imageUrl = (bodyPositionDetail.expose_status === 'Exposed' && bodyPositionDetail.sop_instance_uid)
|
|
|
+ ? getExposedImageUrl(bodyPositionDetail.sop_instance_uid)
|
|
|
+ : getViewIconUrl(bodyPositionDetail.body_position_image);
|
|
|
+```
|
|
|
+
|
|
|
+**风险等级**: 🟡 中 - 需要确保后端数据完整性
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3. 流程切换时的状态同步
|
|
|
+
|
|
|
+**潜在问题**: 从 process 切换到 exam 时,状态可能不同步
|
|
|
+
|
|
|
+**当前处理**: `manualSelectBodyPositionWithFlowSwitch` 中使用事件监听等待流程切换完成
|
|
|
+
|
|
|
+**验证**: ✅ 已有 2秒超时保护机制
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 4. 并发点击
|
|
|
+
|
|
|
+**潜在问题**: 用户快速连续点击多个体位
|
|
|
+
|
|
|
+**当前处理**:
|
|
|
+- 异步操作会按顺序执行
|
|
|
+- Redux 状态更新是同步的,最后一次点击生效
|
|
|
+
|
|
|
+**风险等级**: 🟢 低 - 实际使用场景较少
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📈 实现优势
|
|
|
+
|
|
|
+### 1. 用户体验改善
|
|
|
+
|
|
|
+**之前**:
|
|
|
+- 单击已曝光体位 → 设备重新同步(不必要)
|
|
|
+- 详情区始终显示示意图(不直观)
|
|
|
+
|
|
|
+**现在**:
|
|
|
+- 单击已曝光体位 → 不同步设备(避免不必要操作)
|
|
|
+- 详情区显示实际缩略图(更直观)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 2. 设备资源节省
|
|
|
+
|
|
|
+- 减少不必要的设备通信
|
|
|
+- 避免已曝光体位的重复配置
|
|
|
+- 提高系统响应速度
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 3. 代码清晰度
|
|
|
+
|
|
|
+- 业务逻辑集中在 `selectBodyPositionWithFullLogic`
|
|
|
+- 状态管理遵循单一数据源原则
|
|
|
+- 视图层根据状态自动更新
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🔍 调试建议
|
|
|
+
|
|
|
+### 控制台日志关键字
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 未曝光体位被选中
|
|
|
+'[bodyPositionSelection] Device synced for unexposed position'
|
|
|
+
|
|
|
+// 已曝光体位被选中
|
|
|
+'[bodyPositionSelection] Exposed position selected, skipping device sync'
|
|
|
+
|
|
|
+// 流程切换
|
|
|
+'[bodyPositionSelection] Switching flow from'
|
|
|
+'[bodyPositionSelection] Flow switch completed'
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Redux DevTools 检查点
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 检查体位详情状态
|
|
|
+store.getState().bodyPositionDetail
|
|
|
+// {
|
|
|
+// expose_status: 'Exposed',
|
|
|
+// sop_instance_uid: '1.2.3.4.5...',
|
|
|
+// body_position_image: '/icons/chest_pa.png',
|
|
|
+// // ...
|
|
|
+// }
|
|
|
+
|
|
|
+// 检查选中的体位
|
|
|
+store.getState().bodyPositionList.selectedBodyPosition
|
|
|
+// {
|
|
|
+// dview: {
|
|
|
+// expose_status: 'Exposed',
|
|
|
+// // ...
|
|
|
+// },
|
|
|
+// sop_instance_uid: '1.2.3.4.5...',
|
|
|
+// // ...
|
|
|
+// }
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 断点调试位置
|
|
|
+
|
|
|
+1. **bodyPositionSelection.ts:47** - 进入 exam 模式判断
|
|
|
+2. **bodyPositionSelection.ts:48** - 曝光状态判断分支
|
|
|
+3. **BodyPositionDetail.tsx:20** - 图像 URL 计算
|
|
|
+4. **bodyPositionDetailSlice.ts:40** - extraReducers 字段更新
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📚 相关文档
|
|
|
+
|
|
|
+- [bodyPositionSelection.ts](../../src/domain/exam/bodyPositionSelection.ts) - 体位选择逻辑
|
|
|
+- [bodyPositionDetailSlice.ts](../../src/states/exam/bodyPositionDetailSlice.ts) - 体位详情状态
|
|
|
+- [BodyPositionDetail.tsx](../../src/pages/exam/components/BodyPositionDetail.tsx) - 详情显示组件
|
|
|
+- [BodyPositionList.tsx](../../src/pages/exam/components/BodyPositionList.tsx) - 体位列表组件
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## ✅ 实施检查清单
|
|
|
+
|
|
|
+在实施前,确保以下准备工作已完成:
|
|
|
+
|
|
|
+- [ ] 理解现有的 `manualSelectBodyPositionWithFlowSwitch` 逻辑
|
|
|
+- [ ] 确认 `expose_status` 字段在后端数据中始终存在
|
|
|
+- [ ] 确认 `sop_instance_uid` 在已曝光体位中始终有值
|
|
|
+- [ ] 准备测试数据(包含已曝光和未曝光体位)
|
|
|
+- [ ] 通知测试团队新的交互行为变化
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🚀 实施后验证
|
|
|
+
|
|
|
+完成代码修改后,需要验证:
|
|
|
+
|
|
|
+1. **功能验证**
|
|
|
+ - ✅ Exam 模式单击未曝光 → 设备同步
|
|
|
+ - ✅ Exam 模式单击已曝光 → 不同步设备,显示缩略图
|
|
|
+ - ✅ Process 模式行为保持不变
|
|
|
+
|
|
|
+2. **性能验证**
|
|
|
+ - ✅ 减少设备 API 调用次数
|
|
|
+ - ✅ 图像加载速度正常
|
|
|
+
|
|
|
+3. **兼容性验证**
|
|
|
+ - ✅ 不影响其他页面功能
|
|
|
+ - ✅ 不影响自动选中逻辑
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📞 问题反馈
|
|
|
+
|
|
|
+如遇到问题,请记录以下信息:
|
|
|
+
|
|
|
+1. **场景描述**: 在什么模式下,点击了什么状态的体位
|
|
|
+2. **预期行为**: 应该发生什么
|
|
|
+3. **实际行为**: 实际发生了什么
|
|
|
+4. **控制台日志**: 相关的日志输出
|
|
|
+5. **Redux 状态**: bodyPositionDetail 和 selectedBodyPosition 的值
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+**文档版本**: v1.0
|
|
|
+**创建日期**: 2025/10/13
|
|
|
+**作者**: Cline AI Assistant
|
|
|
+**状态**: ✅ 待实施
|