Browse Source

feat: 实现体位列表单击/双击交互优化功能

核心改动:
1. 单击已曝光体位:只选中并显示缩略图,不同步设备,不切换流程
2. 双击已曝光体位:选中并切换到 process 模式
3. 单击未曝光体位:保持原有逻辑(同步设备)

技术实现:
- 在 bodyPositionSelection 中添加 allowFlowSwitch 参数控制流程切换
- 在 BodyPositionList 中分离单击和双击处理逻辑
- 在 ImageViewer 中添加 onDoubleClick 属性支持
- 在 bodyPositionDetailSlice 中添加 expose_status 和 sop_instance_uid 字段
- 在 BodyPositionDetail 中根据曝光状态动态选择图像源

改动文件:
- src/domain/exam/bodyPositionSelection.ts
- src/pages/exam/components/BodyPositionList.tsx
- src/pages/exam/components/ImageViewer.tsx
- src/pages/exam/components/BodyPositionDetail.tsx
- src/states/exam/bodyPositionDetailSlice.ts
- docs/实现/exam体位列表-单击已曝光体位优化.md (新增)
- docs/实现/exam体位列表-双击已曝光体位进入处理.md (新增)
sw 23 hours ago
parent
commit
e875319128

+ 913 - 0
docs/实现/exam体位列表-单击已曝光体位优化.md

@@ -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  
+**状态**: ✅ 待实施

+ 590 - 0
docs/实现/exam体位列表-双击已曝光体位进入处理.md

@@ -0,0 +1,590 @@
+# Exam 体位列表 - 双击已曝光体位进入处理功能实现文档
+
+## 📋 功能概述
+
+**功能名称**: Exam 页面体位列表 - 双击已曝光体位进入处理
+
+**实现状态**: 🔄 **待实施**
+
+**核心需求**: 区分单击和双击已曝光体位的行为,只有双击才切换到 process 模式
+
+## 🎯 需求说明
+
+### 问题背景
+
+**当前行为**(不符合预期):
+- 在 exam 模式下,**单击**已曝光体位 → 自动切换到 process 模式
+
+**期望行为**:
+- 在 exam 模式下,**单击**已曝光体位 → 只选中并显示缩略图,**不切换流程**
+- 在 exam 模式下,**双击**已曝光体位 → 选中并切换到 process 模式
+
+---
+
+## 📊 完整交互行为表
+
+| 模式 | 体位状态 | 单击行为 | 双击行为 |
+|------|---------|---------|---------|
+| exam | Unexposed | 选中 + 同步设备 + 显示示意图 🖼️ | 同左 |
+| exam | Exposed | **只选中 + 显示缩略图(不切换)** 📷 | **选中 + 切换到 process** ➡️ |
+| process | Unexposed | 选中 + 切换到 exam + 同步设备 | 同左 |
+| process | Exposed | 选中 + 保持 process | 同左 |
+
+---
+
+## 🔍 问题根源分析
+
+### 当前实现问题
+
+在 `src/domain/exam/bodyPositionSelection.ts` 中的 `manualSelectBodyPositionWithFlowSwitch` 函数:
+
+```typescript
+// 第182-187行 - 问题代码
+if (currentKey === 'exam' && isExposed) {
+  targetFlow = 'process';
+  needSwitch = true;  // ❌ 无论单击还是双击都会切换
+  console.log(`Detected exposed position in exam mode, will switch to process`);
+}
+```
+
+**问题**:函数没有区分是单击还是双击触发,导致单击也会切换流程。
+
+---
+
+## 💡 解决方案
+
+### 核心思路
+
+1. **添加参数控制流程切换**:在 `manualSelectBodyPositionWithFlowSwitch` 函数中添加 `allowFlowSwitch` 参数
+2. **分离单击和双击处理**:在 `BodyPositionList.tsx` 中分别处理单击和双击事件
+3. **支持双击事件**:在 `ImageViewer.tsx` 组件中添加 `onDoubleClick` 属性支持
+
+---
+
+## 🛠️ 实现方案
+
+### 修改清单
+
+| 文件 | 修改类型 | 具体内容 |
+|------|---------|---------|
+| `bodyPositionSelection.ts` | 🔧 修改函数签名 | 添加 `allowFlowSwitch` 参数 |
+| `BodyPositionList.tsx` | ➕ 添加双击处理 | 添加 `handleImageDoubleClick` 函数 |
+| `ImageViewer.tsx` | ➕ 添加属性 | 添加 `onDoubleClick` 属性支持 |
+
+---
+
+### 1️⃣ 修改 `bodyPositionSelection.ts`
+
+**文件路径**: `src/domain/exam/bodyPositionSelection.ts`
+
+#### 修改函数签名
+
+```typescript
+/**
+ * 带流程切换的体位选择(用户点击体位时调用,根据曝光状态自动切换流程)
+ * @param bodyPosition 要选中的体位
+ * @param dispatch Redux dispatch
+ * @param currentKey 当前业务流程 key
+ * @param allowFlowSwitch 是否允许自动切换流程(默认 true)
+ */
+export const manualSelectBodyPositionWithFlowSwitch = async (
+  bodyPosition: ExtendedBodyPosition,
+  dispatch: AppDispatch,
+  currentKey: string,
+  allowFlowSwitch: boolean = true  // 🆕 新增参数,默认 true 保持向后兼容
+): Promise<void> => {
+  const isExposed = bodyPosition.dview.expose_status === 'Exposed';
+  const isUnexposed = bodyPosition.dview.expose_status === 'Unexposed';
+
+  let targetFlow = currentKey;
+  let needSwitch = false;
+
+  // 🆕 只有在允许流程切换时才判断是否需要切换
+  if (allowFlowSwitch) {
+    // 判断是否需要切换流程
+    if (currentKey === 'exam' && isExposed) {
+      targetFlow = 'process';
+      needSwitch = true;
+      console.log(
+        `[bodyPositionSelection] Detected exposed position in exam mode, will switch to process`
+      );
+    } else if (currentKey === 'process' && isUnexposed) {
+      targetFlow = 'exam';
+      needSwitch = true;
+      console.log(
+        `[bodyPositionSelection] Detected unexposed position in process mode, will switch to exam`
+      );
+    }
+  } else {
+    console.log(
+      `[bodyPositionSelection] Flow switch disabled, staying in ${currentKey} mode`
+    );
+  }
+
+  // 后续流程切换和体位选中逻辑保持不变
+  if (needSwitch) {
+    // ... 流程切换代码
+  }
+
+  // 使用正确的流程 key 选中体位
+  await selectBodyPositionWithFullLogic(
+    bodyPosition,
+    dispatch,
+    targetFlow,
+    true
+  );
+};
+```
+
+**关键修改**:
+- ✅ 添加 `allowFlowSwitch` 参数(默认 `true` 保持向后兼容)
+- ✅ 只有当 `allowFlowSwitch === true` 时才执行流程切换判断
+- ✅ 添加日志记录是否禁用流程切换
+
+---
+
+### 2️⃣ 修改 `BodyPositionList.tsx`
+
+**文件路径**: `src/pages/exam/components/BodyPositionList.tsx`
+
+#### 添加双击处理函数
+
+```typescript
+// 🆕 新增:处理双击事件
+const handleImageDoubleClick = async (
+  bodyPosition: ExtendedBodyPosition
+): Promise<void> => {
+  console.log(`[BodyPositionList] Double-click on: ${bodyPosition.view_name}`);
+  
+  // 双击时,允许自动流程切换
+  await manualSelectBodyPositionWithFlowSwitch(
+    bodyPosition,
+    dispatch,
+    currentKey,
+    true  // allowFlowSwitch = true(允许流程切换)
+  );
+};
+
+// 🔧 修改:处理单击事件
+const handleImageClick = async (
+  bodyPosition: ExtendedBodyPosition
+): Promise<void> => {
+  console.log(`[BodyPositionList] Single-click on: ${bodyPosition.view_name}`);
+  
+  // 单击时,禁止自动流程切换
+  await manualSelectBodyPositionWithFlowSwitch(
+    bodyPosition,
+    dispatch,
+    currentKey,
+    false  // allowFlowSwitch = false(禁止流程切换)
+  );
+};
+```
+
+#### 修改 ImageViewer 调用
+
+```typescript
+<ImageViewer
+  src={
+    bodyPosition.dview.expose_status === 'Exposed'
+      ? getExposedImageUrl(bodyPosition.sop_instance_uid)
+      : getViewIconUrl(bodyPosition.view_icon_name)
+  }
+  className={`image-viewer-item hover:border-[var(--color-primary)] hover:border-4
+    ${
+      bodyPosition.sop_instance_uid ===
+      selectedBodyPosition?.sop_instance_uid
+        ? 'border-4 border-[var(--color-primary)] '
+        : ''
+    }`}
+  onClick={() => handleImageClick(bodyPosition)}
+  onDoubleClick={() => handleImageDoubleClick(bodyPosition)}  // 🆕 添加双击处理
+/>
+```
+
+---
+
+### 3️⃣ 修改 `ImageViewer.tsx`
+
+**文件路径**: `src/pages/exam/components/ImageViewer.tsx`
+
+#### 添加 onDoubleClick 属性支持
+
+```typescript
+interface ImageViewerProps {
+  src: string;
+  alt?: string;
+  className?: string;
+  onClick?: () => void;
+  onDoubleClick?: () => void;  // 🆕 添加双击属性
+}
+
+const ImageViewer: React.FC<ImageViewerProps> = ({
+  src,
+  alt = 'Body Position',
+  className = '',
+  onClick,
+  onDoubleClick,  // 🆕
+}) => {
+  return (
+    <div className={`image-viewer ${className}`}>
+      <Image
+        src={src}
+        alt={alt}
+        onClick={onClick}
+        onDoubleClick={onDoubleClick}  // 🆕 传递双击事件
+        preview={false}
+        fallback={defaultPosition}
+      />
+    </div>
+  );
+};
+```
+
+**关键修改**:
+- ✅ Interface 中添加 `onDoubleClick?: () => void`
+- ✅ 组件接收 `onDoubleClick` 属性
+- ✅ 将 `onDoubleClick` 传递给 `Image` 组件
+
+---
+
+## 🔄 数据流分析
+
+### 场景1: Exam 模式 - 单击已曝光体位 ⚠️ **核心场景**
+
+```mermaid
+sequenceDiagram
+    actor User as 👤 用户
+    participant IV as ImageViewer
+    participant BPL as BodyPositionList
+    participant BS as bodyPositionSelection
+    participant Redux as Redux Store
+
+    User->>IV: 单击已曝光体位
+    IV->>BPL: onClick() 触发
+    BPL->>BS: manualSelectBodyPositionWithFlowSwitch<br/>(allowFlowSwitch=false)
+    
+    BS->>BS: 检查 allowFlowSwitch === false ✅
+    Note over BS: 跳过流程切换判断
+    BS->>BS: targetFlow = 'exam' (保持当前)
+    
+    BS->>Redux: setSelectedBodyPosition(bodyPosition)
+    Redux-->>User: 更新选中状态 + 显示缩略图 📷
+    
+    Note over User: ❌ 不切换到 process 模式
+```
+
+---
+
+### 场景2: Exam 模式 - 双击已曝光体位 ⚠️ **核心场景**
+
+```mermaid
+sequenceDiagram
+    actor User as 👤 用户
+    participant IV as ImageViewer
+    participant BPL as BodyPositionList
+    participant BS as bodyPositionSelection
+    participant Redux as Redux Store
+
+    User->>IV: 双击已曝光体位
+    IV->>BPL: onDoubleClick() 触发
+    BPL->>BS: manualSelectBodyPositionWithFlowSwitch<br/>(allowFlowSwitch=true)
+    
+    BS->>BS: 检查 allowFlowSwitch === true ✅
+    BS->>BS: 检查 currentKey='exam' && isExposed ✅
+    BS->>BS: targetFlow = 'process' (需要切换)
+    
+    BS->>Redux: setBusinessFlow('process')
+    Note over Redux: exam → process 流程切换
+    
+    BS->>Redux: setSelectedBodyPosition(bodyPosition)
+    Redux-->>User: 切换到 process + 显示缩略图 📷
+    
+    Note over User: ✅ 成功切换到 process 模式
+```
+
+---
+
+### 场景3: Exam 模式 - 单击未曝光体位(保持不变)
+
+```mermaid
+sequenceDiagram
+    actor User as 👤 用户
+    participant IV as ImageViewer
+    participant BPL as BodyPositionList
+    participant BS as bodyPositionSelection
+    participant API as Device API
+
+    User->>IV: 单击未曝光体位
+    IV->>BPL: onClick() 触发
+    BPL->>BS: manualSelectBodyPositionWithFlowSwitch<br/>(allowFlowSwitch=false)
+    
+    BS->>BS: 检查 allowFlowSwitch === false
+    BS->>BS: 但 isUnexposed,不需要切换
+    BS->>BS: targetFlow = 'exam'
+    
+    BS->>API: changeBodyPosition() + setExpEnable()
+    API-->>BS: 设备同步成功
+    
+    Note over User: ✅ 同步设备 + 显示示意图 🖼️
+```
+
+---
+
+## 🧪 测试方案
+
+### 测试环境准备
+
+需要准备包含以下体位的测试数据:
+- 至少 2 个未曝光体位
+- 至少 2 个已曝光体位
+
+---
+
+### 测试场景
+
+#### 场景1: Exam 模式 - 单击已曝光体位 ⚠️ **核心测试**
+
+**前置条件**:在 exam 模式
+
+**测试步骤**:
+1. 单击一个已曝光体位(显示缩略图的)
+
+**预期结果**:
+- ✅ 体位被选中(高亮显示)
+- ✅ BodyPositionDetail 显示该体位的**缩略图**
+- ✅ **保持在 exam 模式**(不切换到 process)
+- ✅ 控制台输出:`Flow switch disabled, staying in exam mode`
+
+**验证方法**:
+```javascript
+// Redux State 检查
+currentKey === 'exam'  // 没有切换流程
+bodyPositionDetail.expose_status === 'Exposed'
+bodyPositionDetail.sop_instance_uid !== ''
+
+// 控制台日志
+'[BodyPositionList] Single-click on: ...'
+'[bodyPositionSelection] Flow switch disabled, staying in exam mode'
+```
+
+---
+
+#### 场景2: Exam 模式 - 双击已曝光体位 ⚠️ **核心测试**
+
+**前置条件**:在 exam 模式
+
+**测试步骤**:
+1. 双击一个已曝光体位(显示缩略图的)
+
+**预期结果**:
+- ✅ 体位被选中
+- ✅ **自动切换到 process 模式**
+- ✅ BodyPositionDetail 显示该体位的缩略图
+- ✅ 控制台输出:`Detected exposed position in exam mode, will switch to process`
+
+**验证方法**:
+```javascript
+// Redux State 检查
+currentKey === 'process'  // 已切换流程
+bodyPositionDetail.expose_status === 'Exposed'
+
+// 控制台日志
+'[BodyPositionList] Double-click on: ...'
+'[bodyPositionSelection] Detected exposed position in exam mode, will switch to process'
+```
+
+---
+
+#### 场景3: Exam 模式 - 单击未曝光体位(保持原逻辑)
+
+**前置条件**:在 exam 模式
+
+**测试步骤**:
+1. 单击一个未曝光体位
+
+**预期结果**:
+- ✅ 体位被选中
+- ✅ 设备同步到该体位
+- ✅ 曝光使能
+- ✅ 保持在 exam 模式
+- ✅ 显示示意图
+
+---
+
+#### 场景4: Exam 模式 - 双击未曝光体位(同单击)
+
+**前置条件**:在 exam 模式
+
+**测试步骤**:
+1. 双击一个未曝光体位
+
+**预期结果**:
+- ✅ 与单击行为相同(因为未曝光体位不需要流程切换)
+
+---
+
+#### 场景5: Process 模式 - 单击/双击行为(保持原逻辑)
+
+**前置条件**:在 process 模式
+
+**测试步骤**:
+1. 单击未曝光体位 → 应切换到 exam
+2. 单击已曝光体位 → 应保持 process
+3. 双击行为应与单击相同
+
+**说明**:process 模式下的行为不受本次修改影响,保持原有逻辑。
+
+---
+
+### 测试用例总结表
+
+| 模式 | 体位状态 | 操作 | 流程切换 | 设备同步 | 详情显示 | 优先级 |
+|------|---------|------|---------|---------|---------|--------|
+| exam | Unexposed | 单击 | ❌ 不切换 | ✅ 同步 | 示意图 🖼️ | P0 |
+| exam | Unexposed | 双击 | ❌ 不切换 | ✅ 同步 | 示意图 🖼️ | P1 |
+| exam | Exposed | **单击** | **❌ 不切换** | **❌ 跳过** | **缩略图 📷** | **P0** |
+| exam | Exposed | **双击** | **✅ 切换到 process** | **❌ 跳过** | **缩略图 📷** | **P0** |
+| process | Unexposed | 单击/双击 | ✅ 切换到 exam | ✅ 同步 | 示意图 🖼️ | P1 |
+| process | Exposed | 单击/双击 | ❌ 不切换 | ❌ 跳过 | 缩略图 📷 | P1 |
+
+---
+
+## 🐛 边界情况分析
+
+### 1. 双击误触发两次单击
+
+**问题描述**:双击时可能先触发单击事件,再触发双击事件
+
+**解决方案**:
+- React 的 `onDoubleClick` 事件会自动处理这种情况
+- 浏览器默认行为会在双击时抑制单击事件的完整执行
+- 实际测试中验证是否需要添加防抖逻辑
+
+**风险等级**:🟡 中
+
+---
+
+### 2. 快速连续单击(非双击)
+
+**问题描述**:用户快速连续单击两次,但不是双击(间隔较长)
+
+**当前处理**:每次单击都会独立处理,不会误判为双击
+
+**风险等级**:🟢 低
+
+---
+
+### 3. 流程切换中的状态同步
+
+**问题描述**:双击触发流程切换时,状态可能不一致
+
+**当前处理**:已有事件监听机制(`BUSINESS_FLOW_CHANGED`)确保流程切换完成
+
+**风险等级**:🟢 低(已有保护)
+
+---
+
+## 📈 实现优势
+
+### 1. 用户体验改善
+
+**之前**:
+- 单击已曝光体位 → 意外切换到 process(用户困惑)
+
+**现在**:
+- 单击已曝光体位 → 只查看,不切换(符合预期)
+- 双击已曝光体位 → 明确意图进入处理(操作清晰)
+
+---
+
+### 2. 操作直觉性
+
+- **单击** = 查看/选择(低风险操作)
+- **双击** = 进入/打开(高风险操作)
+
+符合用户对单击/双击的常规认知。
+
+---
+
+### 3. 向后兼容
+
+- `allowFlowSwitch` 参数默认 `true`
+- 现有调用不需要修改
+- 渐进式升级
+
+---
+
+## 🔍 调试建议
+
+### 控制台日志关键字
+
+```javascript
+// 单击已曝光体位
+'[BodyPositionList] Single-click on: ...'
+'[bodyPositionSelection] Flow switch disabled, staying in exam mode'
+
+// 双击已曝光体位
+'[BodyPositionList] Double-click on: ...'
+'[bodyPositionSelection] Detected exposed position in exam mode, will switch to process'
+
+// 流程切换
+'[bodyPositionSelection] Switching flow from exam to process'
+'[bodyPositionSelection] Flow switch completed'
+```
+
+---
+
+### 断点调试位置
+
+1. **BodyPositionList.tsx:35** - `handleImageClick` 函数
+2. **BodyPositionList.tsx:45** - `handleImageDoubleClick` 函数
+3. **bodyPositionSelection.ts:182** - `allowFlowSwitch` 判断分支
+4. **bodyPositionSelection.ts:187** - `currentKey === 'exam' && isExposed` 判断
+
+---
+
+## 📚 相关文档
+
+- [bodyPositionSelection.ts](../../src/domain/exam/bodyPositionSelection.ts) - 体位选择逻辑
+- [BodyPositionList.tsx](../../src/pages/exam/components/BodyPositionList.tsx) - 体位列表组件
+- [ImageViewer.tsx](../../src/pages/exam/components/ImageViewer.tsx) - 图像查看组件
+- [exam体位列表-单击已曝光体位优化.md](./exam体位列表-单击已曝光体位优化.md) - 之前的单击优化文档
+
+---
+
+## ✅ 实施检查清单
+
+- [ ] 理解单击/双击的区别和实现原理
+- [ ] 确认 `allowFlowSwitch` 参数的默认值和向后兼容性
+- [ ] 准备包含已曝光和未曝光体位的测试数据
+- [ ] 通知测试团队新的交互行为(单击不切换,双击切换)
+- [ ] 验证双击不会误触发两次单击事件
+
+---
+
+## 🚀 实施后验证
+
+完成代码修改后,需要验证:
+
+1. **功能验证**
+   - ✅ Exam 模式单击已曝光 → 不切换流程
+   - ✅ Exam 模式双击已曝光 → 切换到 process
+   - ✅ 其他场景行为保持不变
+
+2. **交互验证**
+   - ✅ 单击响应迅速,无延迟
+   - ✅ 双击不会误触发两次单击
+   - ✅ 操作反馈清晰
+
+3. **兼容性验证**
+   - ✅ 不影响其他页面功能
+   - ✅ 不影响自动选中逻辑
+   - ✅ 向后兼容现有调用
+
+---
+
+**文档版本**: v1.0  
+**创建日期**: 2025/10/13  
+**作者**: Cline AI Assistant  
+**状态**: ✅ 待实施

+ 47 - 20
src/domain/exam/bodyPositionSelection.ts

@@ -43,21 +43,36 @@ export const selectBodyPositionWithFullLogic = async (
         collimator_length: bodyPosition.collimator_length,
         collimator_width: bodyPosition.collimator_width,
         sid: bodyPosition.sid,
+        // 🆕 添加新字段
+        expose_status: bodyPosition.dview.expose_status,
+        sop_instance_uid: bodyPosition.sop_instance_uid,
       })
     );
 
-    // 3. 如果在exam模式,同步设备到对应体位
+    // 3. 🆕 如果在exam模式,根据曝光状态决定是否同步设备
     if (currentKey === 'exam') {
-      await changeBodyPosition(bodyPosition.sop_instance_uid);
+      if (bodyPosition.dview.expose_status === 'Unexposed') {
+        // 未曝光体位:同步设备,使能曝光
+        await changeBodyPosition(bodyPosition.sop_instance_uid);
 
-      // 切换体位成功后,使能发生器曝光
-      await setExpEnable();
+        // 切换体位成功后,使能发生器曝光
+        await setExpEnable();
 
-      const successMsg = `Body position changed successfully: ${bodyPosition.view_name}`;
-      console.log(`[bodyPositionSelection] ${successMsg}`);
+        const successMsg = `Body position changed successfully: ${bodyPosition.view_name}`;
+        console.log(`[bodyPositionSelection] ${successMsg}`);
 
-      if (showMessage) {
-        message.success(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(
@@ -154,11 +169,16 @@ export const manualSelectBodyPosition = async (
 
 /**
  * 带流程切换的体位选择(用户点击体位时调用,根据曝光状态自动切换流程)
+ * @param bodyPosition 要选中的体位
+ * @param dispatch Redux dispatch
+ * @param currentKey 当前业务流程 key
+ * @param allowFlowSwitch 是否允许自动切换流程(默认 true)
  */
 export const manualSelectBodyPositionWithFlowSwitch = async (
   bodyPosition: ExtendedBodyPosition,
   dispatch: AppDispatch,
-  currentKey: string
+  currentKey: string,
+  allowFlowSwitch: boolean = true
 ): Promise<void> => {
   const isExposed = bodyPosition.dview.expose_status === 'Exposed';
   const isUnexposed = bodyPosition.dview.expose_status === 'Unexposed';
@@ -166,18 +186,25 @@ export const manualSelectBodyPositionWithFlowSwitch = async (
   let targetFlow = currentKey;
   let needSwitch = false;
 
-  // 判断是否需要切换流程
-  if (currentKey === 'exam' && isExposed) {
-    targetFlow = 'process';
-    needSwitch = true;
-    console.log(
-      `[bodyPositionSelection] Detected exposed position in exam mode, will switch to process`
-    );
-  } else if (currentKey === 'process' && isUnexposed) {
-    targetFlow = 'exam';
-    needSwitch = true;
+  // 🆕 只有在允许流程切换时才判断是否需要切换
+  if (allowFlowSwitch) {
+    // 判断是否需要切换流程
+    if (currentKey === 'exam' && isExposed) {
+      targetFlow = 'process';
+      needSwitch = true;
+      console.log(
+        `[bodyPositionSelection] Detected exposed position in exam mode, will switch to process`
+      );
+    } else if (currentKey === 'process' && isUnexposed) {
+      targetFlow = 'exam';
+      needSwitch = true;
+      console.log(
+        `[bodyPositionSelection] Detected unexposed position in process mode, will switch to exam`
+      );
+    }
+  } else {
     console.log(
-      `[bodyPositionSelection] Detected unexposed position in process mode, will switch to exam`
+      `[bodyPositionSelection] Flow switch disabled, staying in ${currentKey} mode`
     );
   }
 

+ 8 - 3
src/pages/exam/components/BodyPositionDetail.tsx

@@ -1,7 +1,7 @@
 import React, { useRef } from 'react';
 import { useSelector } from 'react-redux';
 import { Row, Col, Typography, Image, Flex } from 'antd';
-import { getViewIconUrl } from '@/API/bodyPosition';
+import { getViewIconUrl, getExposedImageUrl } from '@/API/bodyPosition';
 import CollimatorIcon from '../../../assets/imgs/Collimator_normal.png';
 import SidIcon from '../../../assets/imgs/SID.png';
 import { RootState } from '@/states/store';
@@ -14,6 +14,11 @@ const BodyPositionDetail: React.FC = () => {
   );
   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">
       {/* 第一行 : 患者姓名*/}
@@ -35,7 +40,7 @@ const BodyPositionDetail: React.FC = () => {
         </Col>
       </Row>
 
-      {/* 第三行 :体位示意图*/}
+      {/* 第三行 :体位示意图或缩略图 🆕 修改此处 */}
       <Flex
         //flex={1}                       // 吃掉剩余宽高
         style={{ minHeight: 0 }} // 保险丝
@@ -49,7 +54,7 @@ const BodyPositionDetail: React.FC = () => {
             // height: '300px',
             width: '100%', // 随父容器宽
             //aspectRatio: '16 / 9',        // 根据图片真实比例改,如 4/3、1/1 等
-            backgroundImage: `url(${getViewIconUrl(bodyPositionDetail.body_position_image)})`,
+            backgroundImage: `url(${imageUrl})`,  // 🆕 使用动态 imageUrl
             backgroundSize: 'contain', // cover 亦可
             backgroundPosition: 'center',
             backgroundRepeat: 'no-repeat',

+ 22 - 1
src/pages/exam/components/BodyPositionList.tsx

@@ -27,13 +27,33 @@ const BodyPositionList: React.FC<BodyPositionListProps> = ({
     (state: RootState) => state.BusinessFlow.currentKey
   );
 
+  // 🔧 修改:处理单击事件
   const handleImageClick = async (
     bodyPosition: ExtendedBodyPosition
   ): Promise<void> => {
+    console.log(`[BodyPositionList] Single-click on: ${bodyPosition.view_name}`);
+    
+    // 单击时,禁止自动流程切换
     await manualSelectBodyPositionWithFlowSwitch(
       bodyPosition,
       dispatch,
-      currentKey
+      currentKey,
+      false  // allowFlowSwitch = false(禁止流程切换)
+    );
+  };
+
+  // 🆕 新增:处理双击事件
+  const handleImageDoubleClick = async (
+    bodyPosition: ExtendedBodyPosition
+  ): Promise<void> => {
+    console.log(`[BodyPositionList] Double-click on: ${bodyPosition.view_name}`);
+    
+    // 双击时,允许自动流程切换
+    await manualSelectBodyPositionWithFlowSwitch(
+      bodyPosition,
+      dispatch,
+      currentKey,
+      true  // allowFlowSwitch = true(允许流程切换)
     );
   };
   const bodyPositions = useSelector(
@@ -99,6 +119,7 @@ const BodyPositionList: React.FC<BodyPositionListProps> = ({
                     : ''
                 }`}
               onClick={() => handleImageClick(bodyPosition)}
+              onDoubleClick={() => handleImageDoubleClick(bodyPosition)}
             />
             {/* 左上角:判断状态badge */}
             {bodyPosition.dview.expose_status === 'Exposed' && bodyPosition.dview.judged_status && (

+ 3 - 0
src/pages/exam/components/ImageViewer.tsx

@@ -7,6 +7,7 @@ interface ImageViewerProps {
   alt?: string;
   className?: string;
   onClick?: () => void;
+  onDoubleClick?: () => void;  // 🆕 添加双击属性
 }
 
 const ImageViewer: React.FC<ImageViewerProps> = ({
@@ -14,6 +15,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
   alt,
   className,
   onClick,
+  onDoubleClick,  // 🆕
 }) => {
   return (
     <div className={`image-viewer ${className}`}>
@@ -21,6 +23,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
         src={src}
         alt={alt}
         onClick={onClick}
+        onDoubleClick={onDoubleClick}  // 🆕 传递双击事件
         preview={false}
         fallback={defaultPosition}
       />

+ 9 - 0
src/states/exam/bodyPositionDetailSlice.ts

@@ -14,6 +14,9 @@ interface BodyPositionDetailState {
   collimator_length: string | number;
   collimator_width: string | number;
   sid: string;
+  // 🆕 添加以下字段
+  expose_status: string;         // 'Exposed' | 'Unexposed'
+  sop_instance_uid: string;      // 用于获取缩略图 URL
   // Add other relevant fields as needed
 }
 
@@ -29,6 +32,9 @@ const initialState: BodyPositionDetailState = {
   collimator_length: '',
   collimator_width: '',
   sid: '',
+  // 🆕 添加初始值
+  expose_status: 'Unexposed',
+  sop_instance_uid: '',
   // Initialize other fields as needed
 };
 
@@ -63,6 +69,9 @@ const bodyPositionDetailSlice = createSlice({
           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;