功能名称: Exam 页面体位列表单击已曝光体位行为优化
实现状态: 🔄 待实现
核心需求: 优化 Exam 页面体位列表的交互行为,区分已曝光和未曝光体位的单击响应:
changeBodyPosition
、不调用 setExpEnable
)BodyPositionDetail
中显示已曝光的缩略图(而非示意图)文件路径: src/pages/exam/components/BodyPositionList.tsx
元素 | 类型 | 职责 | 当前行为 |
---|---|---|---|
BodyPositionList | React Component | 渲染体位列表,显示体位图像(示意图或缩略图) | 已实现 |
handleImageClick | Method | 处理体位图像点击事件 | 调用 manualSelectBodyPositionWithFlowSwitch |
ImageViewer | Component | 显示单个体位图像 | 根据 expose_status 选择图像源 |
关键代码:
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)}
/>
文件路径: src/pages/exam/components/BodyPositionDetail.tsx
元素 | 类型 | 职责 | 需要修改 |
---|---|---|---|
BodyPositionDetail | React Component | 显示选中体位的详细信息 | ✅ 需要修改 |
体位图像显示区域 | UI Element | 第三行,显示体位大图 | ✅ 需要根据曝光状态切换图像源 |
当前显示逻辑(只显示示意图):
backgroundImage: `url(${getViewIconUrl(bodyPositionDetail.body_position_image)})`
需要改为(根据曝光状态选择):
const imageUrl = bodyPositionDetail.expose_status === 'Exposed'
? getExposedImageUrl(bodyPositionDetail.sop_instance_uid) // 已曝光:缩略图
: getViewIconUrl(bodyPositionDetail.body_position_image); // 未曝光:示意图
backgroundImage: `url(${imageUrl})`
文件路径: src/domain/exam/bodyPositionSelection.ts
函数 | 职责 | 需要修改 |
---|---|---|
selectBodyPositionWithFullLogic | 核心选中逻辑,处理状态更新和设备同步 | ✅ 需要修改 |
manualSelectBodyPositionWithFlowSwitch | 带流程切换的手动选中(用户点击时调用) | ✅ 已实现自动流程切换 |
autoSelectFirstBodyPosition | 自动选中第一个体位 | ✅ 无需修改 |
当前逻辑(exam 模式下无条件同步设备):
// 3. 如果在exam模式,同步设备到对应体位
if (currentKey === 'exam') {
await changeBodyPosition(bodyPosition.sop_instance_uid);
await setExpEnable();
// ...
}
需要改为(仅未曝光时同步设备):
// 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`);
}
}
文件路径: src/states/exam/bodyPositionDetailSlice.ts
元素 | 类型 | 职责 | 需要修改 |
---|---|---|---|
BodyPositionDetailState | Interface | 体位详情状态定义 | ✅ 需要添加字段 |
setBodyPositionDetail | Reducer | 设置体位详情 | ✅ 需要更新字段 |
extraReducers | Listener | 监听 setSelectedBodyPosition ,自动更新详情 |
✅ 需要更新字段 |
需要添加的字段:
interface BodyPositionDetailState {
// ... 现有字段
expose_status: string; // 🆕 'Exposed' | 'Unexposed'
sop_instance_uid: string; // 🆕 用于获取缩略图 URL
}
初始状态:
const initialState: BodyPositionDetailState = {
// ... 现有字段
expose_status: 'Unexposed', // 🆕 默认未曝光
sop_instance_uid: '', // 🆕 默认空
};
Reducer 更新:
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;
});
文件路径: src/API/bodyPosition.ts
函数 | 职责 | 状态 |
---|---|---|
getViewIconUrl | 获取体位示意图 URL | ✅ 已实现 |
getExposedImageUrl | 获取已曝光图像的缩略图 URL | ✅ 已实现 |
// 示意图 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`;
}
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: 显示示意图 🖼️
关键步骤:
changeBodyPosition
- 同步设备setExpEnable
- 使能曝光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: 显示缩略图 📷
关键差异:
changeBodyPosition
- 不同步设备setExpEnable
- 不使能曝光expose_status
和 sop_instance_uid
)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 动态选择图像源 |
bodyPositionSelection.ts
文件: src/domain/exam/bodyPositionSelection.ts
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
bodyPositionDetailSlice.ts
文件: src/states/exam/bodyPositionDetailSlice.ts
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
}
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: '',
};
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;
});
},
BodyPositionDetail.tsx
文件: src/pages/exam/components/BodyPositionDetail.tsx
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
importimageUrl
计算逻辑(根据 expose_status
选择)imageUrl
而非固定的 getViewIconUrl
前置条件: 在 exam 模式,有未曝光体位
测试步骤:
预期结果:
验证方法:
// Redux State 检查
bodyPositionDetail.expose_status === 'Unexposed'
bodyPositionDetail.body_position_image !== ''
// 控制台日志
'[bodyPositionSelection] Device synced for unexposed position'
前置条件: 在 exam 模式,有已曝光体位
测试步骤:
预期结果:
changeBodyPosition
setExpEnable
验证方法:
// Redux State 检查
bodyPositionDetail.expose_status === 'Exposed'
bodyPositionDetail.sop_instance_uid !== ''
// 控制台日志
'[bodyPositionSelection] Exposed position selected, skipping device sync'
// 图像 URL 检查
backgroundImage.includes('/pub/thumbnail/') // 缩略图路径
前置条件: 在 process 模式
测试步骤:
预期结果:
前置条件: 在 process 模式
测试步骤:
预期结果:
前置条件: 在 exam 模式,有未曝光体位
测试步骤:
预期结果:
场景 | 模式 | 体位状态 | 设备同步 | 详情显示 | 优先级 |
---|---|---|---|---|---|
1 | exam | Unexposed | ✅ 执行 | 示意图 🖼️ | P0 |
2 | exam | Exposed | ❌ 跳过 | 缩略图 📷 | P0 |
3 | process | Unexposed | ✅ 执行(切换后) | 示意图 🖼️ | P1 |
4 | process | Exposed | ❌ 跳过 | 缩略图 📷 | P1 |
5 | exam | Unexposed → Exposed | 第一次✅ / 第二次❌ | 示意图→缩略图 | P1 |
潜在问题: 如果 dview.expose_status
字段不存在或为 undefined
处理方案:
// 添加防护判断
if (bodyPosition.dview.expose_status === 'Unexposed') {
// 同步设备
}
说明: undefined !== 'Unexposed'
,所以会跳过设备同步,行为安全。
潜在问题: 已曝光体位但 sop_instance_uid
为空字符串
影响:
getExposedImageUrl('')
会返回 /pub/thumbnail/.webp
处理方案:
// 在 BodyPositionDetail.tsx 中添加防护
const imageUrl = (bodyPositionDetail.expose_status === 'Exposed' && bodyPositionDetail.sop_instance_uid)
? getExposedImageUrl(bodyPositionDetail.sop_instance_uid)
: getViewIconUrl(bodyPositionDetail.body_position_image);
风险等级: 🟡 中 - 需要确保后端数据完整性
潜在问题: 从 process 切换到 exam 时,状态可能不同步
当前处理: manualSelectBodyPositionWithFlowSwitch
中使用事件监听等待流程切换完成
验证: ✅ 已有 2秒超时保护机制
潜在问题: 用户快速连续点击多个体位
当前处理:
风险等级: 🟢 低 - 实际使用场景较少
之前:
现在:
selectBodyPositionWithFullLogic
// 未曝光体位被选中
'[bodyPositionSelection] Device synced for unexposed position'
// 已曝光体位被选中
'[bodyPositionSelection] Exposed position selected, skipping device sync'
// 流程切换
'[bodyPositionSelection] Switching flow from'
'[bodyPositionSelection] Flow switch completed'
// 检查体位详情状态
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...',
// // ...
// }
在实施前,确保以下准备工作已完成:
manualSelectBodyPositionWithFlowSwitch
逻辑expose_status
字段在后端数据中始终存在sop_instance_uid
在已曝光体位中始终有值完成代码修改后,需要验证:
功能验证
性能验证
兼容性验证
如遇到问题,请记录以下信息:
文档版本: v1.0
创建日期: 2025/10/13
作者: Cline AI Assistant
状态: ✅ 待实施