Procházet zdrojové kódy

feat: 实现模拟/真实环境的视觉区分和交互控制

修改: src/states/productSlice.ts, src/pages/exam/DeviceArea.tsx

新增: docs/实现/模拟状态的呈现效果.md
sw před 1 týdnem
rodič
revize
8de77a666a

+ 566 - 0
docs/实现/模拟状态的呈现效果.md

@@ -0,0 +1,566 @@
+# 模拟状态的呈现效果
+
+## 1. 需求描述
+
+### 1.1 业务场景
+
+系统支持模拟器和真实设备两种工作模式。当系统运行在模拟器模式下时,某些硬件操作(如曝光操作)无法真实执行,需要在UI上明确标识这种状态。
+
+### 1.2 具体需求
+
+从 API `dr/api/v1/pub/software_info` 可以获取设备类型信息:
+
+- `FPD: "Simulator"` - 表示平板探测器为模拟设备
+- `FPD: "Physics"` - 表示平板探测器为真实物理设备
+
+**需求**:
+
+- 当 `FPD === "Simulator"` 时(模拟环境),`DeviceArea.tsx` 中的"曝光指示器"按钮应:
+
+  1. **可以点击**(用于模拟测试)
+  2. **显示"模拟"标识**(使用橙色 Badge 角标)
+  3. **保持状态颜色显示**(如绿色表示就绪)
+
+- 当 `FPD === "Physics"` 时(真实环境),"曝光指示器"按钮应:
+  1. **不可点击**(真实曝光需通过硬件触发,防止误操作)
+  2. **不显示"模拟"角标**
+  3. **保持状态颜色显示**(如绿色表示就绪)
+
+### 1.3 用户体验目标
+
+- 用户能够清晰识别当前是否在模拟环境下工作
+- 模拟环境:允许点击按钮进行模拟测试,显示橙色"模拟"角标
+- 真实环境:禁止点击按钮(硬件触发),无角标显示
+- 两种环境下都保持设备状态颜色指示(绿色/黄色/红色等)
+
+---
+
+## 2. 技术方案对比
+
+### 方案 1:扩展现有的 productSlice(推荐)✅
+
+#### 优点
+
+- ✅ 改动最小,softwareInfo 已经在 productSlice 中获取
+- ✅ 逻辑集中,所有软件配置信息在一个地方管理
+- ✅ 无需新建文件和额外的初始化逻辑
+
+#### 缺点
+
+- ⚠️ productSlice 会包含设备相关信息,职责略微混合
+
+#### 实现步骤
+
+1. 修改 `src/states/productSlice.ts`
+   - 在 ProductState 接口中添加 `fpd` 和 `gen` 字段
+   - 更新 `initializeProductState` thunk,将 FPD 和 GEN 信息存入 Redux
+2. 修改 `src/pages/exam/DeviceArea.tsx`
+   - 从 Redux 读取 FPD 状态
+   - 在 onClick 中判断环境类型:真实环境阻止执行,模拟环境允许执行
+   - 使用 Ant Design Badge 组件在模拟环境下显示"模拟"标识
+   - 不使用 disabled 属性,保持状态颜色显示
+
+---
+
+### 方案 2:创建独立的 deviceInfoSlice
+
+#### 优点
+
+- ✅ 职责分离更清晰,设备信息独立管理
+- ✅ 便于未来扩展更多设备相关状态
+
+#### 缺点
+
+- ❌ 需要新建文件 `src/states/device/deviceInfoSlice.ts`
+- ❌ 需要在 store 中注册新的 reducer
+- ❌ 需要额外的初始化逻辑,可能重复调用 API
+
+---
+
+### 方案 3:在 DeviceArea 组件内部管理状态
+
+#### 优点
+
+- ✅ 组件自包含,不依赖全局状态
+
+#### 缺点
+
+- ❌ 如果其他组件也需要 FPD 状态,会导致重复请求
+- ❌ 组件职责过重
+- ❌ 难以测试和维护
+
+---
+
+## 3. UI 展示方案对比
+
+### 方案 A:onClick 判断 + Badge 角标(最终采用)✅
+
+```tsx
+import { Badge } from 'antd';
+
+<Badge count={isSimulator ? '模拟' : 0} offset={[-5, 5]} color="orange">
+  <Button
+    style={btnStyle}
+    icon={<CameraOutlined />}
+    onClick={() => {
+      if (!isSimulator) {
+        // 真实环境下禁止点击
+        console.warn('真实环境下,曝光操作需要通过硬件触发');
+        return;
+      }
+      // 模拟环境下允许点击
+      triggerInspection();
+    }}
+    title={`曝光指示器: ${exposureStatus}${isSimulator ? ' (模拟模式)' : ' (真实模式)'}`}
+  />
+</Badge>;
+```
+
+**效果**:
+
+- 模拟环境:显示橙色"模拟"角标,按钮可点击
+- 真实环境:无角标,按钮点击无效
+- 两种环境都保持状态颜色(如 text-green-500)
+
+**优点**:
+
+- ✅ 保留状态颜色指示
+- ✅ 清晰的视觉区分(模拟有角标,真实无角标)
+- ✅ 不使用 disabled 属性,避免灰色覆盖
+- ✅ 符合实际使用场景(模拟可测试,真实靠硬件)
+
+---
+
+### 方案 B:使用 disabled 属性(已弃用)
+
+**缺点**:
+
+- ❌ disabled 会应用灰色样式,覆盖状态颜色
+- ❌ 无法同时显示禁用状态和设备状态颜色
+- ❌ 不符合需求(模拟环境需要可点击)
+
+---
+
+## 4. 推荐方案总结
+
+### 最佳组合
+
+**状态管理**:方案 1(扩展 productSlice)  
+**UI 展示**:方案 A(onClick 判断 + Badge 角标)
+
+### 理由
+
+1. **开发效率高**:复用现有的 softwareInfo 获取逻辑
+2. **用户体验好**:
+   - 模拟环境:显示角标 + 可点击(便于测试)
+   - 真实环境:无角标 + 不可点击(防止误操作)
+   - 两种环境都保持状态颜色指示
+3. **代码改动小**:只需修改两个文件
+4. **易于维护**:逻辑集中,便于未来扩展
+5. **符合实际场景**:真实设备曝光由硬件触发,软件按钮仅作状态显示
+
+---
+
+## 5. 涉及的核心概念
+
+### 5.1 软件信息(SoftwareInfo)
+
+**接口定义**:
+
+```typescript
+interface SoftwareInfo {
+  FPD: string; // 平板探测器类型
+  GEN: string; // 发生器类型
+  language: string[]; // 支持的语言列表
+  product: string; // 产品名称
+  guest: string; // 急诊访问 token
+  sn: string; // 序列号
+  server: Record<string, any>; // 服务器信息
+}
+```
+
+**设备类型**:
+
+- `"Simulator"` - 模拟设备
+- `"Physics"` - 物理设备
+
+### 5.2 Redux 状态管理
+
+**productSlice 的作用**:
+
+- 存储产品相关的全局配置信息
+- 在应用初始化时自动获取软件信息
+- 提供给各个组件使用
+
+**数据流**:
+
+```
+应用启动
+  ↓
+dispatch(initializeProductState())
+  ↓
+调用 fetchSoftwareInfo() API
+  ↓
+Redux store 更新(包含 fpd, gen)
+  ↓
+组件通过 useSelector 读取状态
+  ↓
+根据状态渲染 UI
+```
+
+### 5.3 Ant Design Badge 组件
+
+**用途**:在按钮或图标上显示小标识
+
+**关键属性**:
+
+- `count`: 显示的内容(数字或文字)
+- `offset`: 位置偏移 `[x, y]`
+- `color`: 角标颜色
+- `size`: 角标大小
+
+---
+
+## 6. 详细实现方案
+
+### 6.1 修改 productSlice.ts
+
+**文件路径**:`src/states/productSlice.ts`
+
+#### 步骤 1:扩展 State 接口
+
+```typescript
+interface ProductState {
+  productName: 'DROS' | 'VETDROS';
+  language: string;
+  source: 'Electron' | 'Browser' | 'Android';
+  guest: string;
+  fpd: string; // ⭐ 新增:平板探测器类型
+  gen: string; // ⭐ 新增:发生器类型
+}
+
+const initialState: ProductState = {
+  productName: 'DROS',
+  language: 'en',
+  source: 'Browser',
+  guest: '',
+  fpd: '', // ⭐ 新增
+  gen: '', // ⭐ 新增
+};
+```
+
+#### 步骤 2:更新 initializeProductState thunk
+
+```typescript
+export const initializeProductState = createAsyncThunk(
+  'product/initializeProductState',
+  async () => {
+    const softwareInfo = await fetchSoftwareInfo();
+    console.log(`加载软件系统信息:${JSON.stringify(softwareInfo)}`);
+    return {
+      productName: softwareInfo.product as 'DROS' | 'VETDROS',
+      language: softwareInfo.language[0],
+      source: 'Browser' as const,
+      guest: softwareInfo.guest,
+      fpd: softwareInfo.FPD, // ⭐ 新增
+      gen: softwareInfo.GEN, // ⭐ 新增
+    };
+  }
+);
+```
+
+#### 步骤 3:更新 Reducers
+
+```typescript
+const productSlice = createSlice({
+  name: 'product',
+  initialState,
+  reducers: {
+    setProduct: (state, action: PayloadAction<ProductState>) => {
+      state.productName = action.payload.productName;
+      state.language = action.payload.language;
+      state.source = action.payload.source;
+      state.guest = action.payload.guest;
+      state.fpd = action.payload.fpd; // ⭐ 新增
+      state.gen = action.payload.gen; // ⭐ 新增
+    },
+  },
+  extraReducers: (builder) => {
+    builder
+      .addCase(initializeProductState.fulfilled, (state, action) => {
+        state.productName = action.payload.productName;
+        state.language = action.payload.language;
+        state.source = action.payload.source;
+        state.guest = action.payload.guest;
+        state.fpd = action.payload.fpd; // ⭐ 新增
+        state.gen = action.payload.gen; // ⭐ 新增
+      })
+      .addCase(initializeProductState.rejected, (state, action) => {
+        console.error('Failed to initialize product state:', action.error);
+      });
+  },
+});
+```
+
+---
+
+### 6.2 修改 DeviceArea.tsx
+
+**文件路径**:`src/pages/exam/DeviceArea.tsx`
+
+#### 完整代码
+
+```tsx
+import { Flex, Button, Badge } from 'antd';
+import {
+  ToolOutlined,
+  CameraOutlined,
+  TabletOutlined,
+} from '@ant-design/icons';
+import { useSelector } from 'react-redux';
+import { RootState } from '@/states/store';
+import {
+  GENERATOR_STATUS,
+  GeneratorStatus,
+} from '@/states/exam/deviceAreaSlice';
+import triggerInspection from '../../API/exam/triggerInspection';
+
+const DeviceArea = ({ className }: { className?: string }) => {
+  // 现有的状态选择器
+  const generatorStatus = useSelector(
+    (state: RootState) => state.deviceArea.generatorStatus
+  );
+  const generatorStatus_2 = useSelector(
+    (state: RootState) => state.deviceArea.generatorStatus_2
+  );
+  const exposureStatus = useSelector(
+    (state: RootState) => state.deviceArea.exposureStatus
+  );
+  const tabletStatus = useSelector(
+    (state: RootState) => state.deviceArea.tabletStatus
+  );
+
+  // ⭐ 新增:读取 FPD 状态
+  const fpd = useSelector((state: RootState) => state.product.fpd);
+  const isSimulator = fpd === 'Simulator';
+
+  const btnStyle = { width: '1.5rem', height: '1.5rem' };
+  const classValue = 'mr-1';
+
+  return (
+    <Flex justify="end" align="center" className={`w-full ${className}`}>
+      {/* 手闸状态指示器 - 保持不变 */}
+      <Button
+        style={btnStyle}
+        className={classValue}
+        icon={
+          <ToolOutlined
+            className={
+              generatorStatus_2 === GENERATOR_STATUS.GENERATOR_STATUS_STANDBY
+                ? 'text-green-500'
+                : generatorStatus === GeneratorStatus.GENERATOR_RAD_PREPARE
+                  ? 'text-yellow-500'
+                  : generatorStatus === GeneratorStatus.GENERATOR_RAD_READY
+                    ? 'text-yellow-500'
+                    : ''
+            }
+          />
+        }
+        title={`手闸状态指示器: ${generatorStatus}`}
+      />
+
+      {/* ⭐ 曝光指示器 - 添加模拟状态标识 */}
+      <Badge count={isSimulator ? '模拟' : 0} offset={[-5, 5]} color="orange">
+        <Button
+          style={btnStyle}
+          data-testid="device-all-ready"
+          className={`${classValue} ${
+            exposureStatus === 'ready'
+              ? 'text-green-500'
+              : exposureStatus === 'not_ready'
+                ? ''
+                : ''
+          }`}
+          icon={<CameraOutlined />}
+          onClick={() => {
+            if (!isSimulator) {
+              // 真实环境下禁止点击
+              console.warn('真实环境下,曝光操作需要通过硬件触发');
+              return;
+            }
+            // 模拟环境下允许点击
+            triggerInspection();
+          }}
+          title={`曝光指示器: ${exposureStatus}${isSimulator ? ' (模拟模式)' : ' (真实模式)'}`}
+        />
+      </Badge>
+
+      {/* 平板指示器 - 保持不变 */}
+      <Button
+        style={btnStyle}
+        className={`${classValue} ${
+          tabletStatus === 'exposing'
+            ? 'text-yellow-500'
+            : tabletStatus === 'ready'
+              ? 'text-green-500'
+              : tabletStatus === 'error'
+                ? 'text-red-500'
+                : ''
+        }`}
+        icon={<TabletOutlined />}
+        title={`平板指示器: ${tabletStatus}`}
+      />
+    </Flex>
+  );
+};
+
+export default DeviceArea;
+```
+
+#### 关键修改点
+
+1. **导入 Badge 组件**:
+
+   ```tsx
+   import { Flex, Button, Badge } from 'antd';
+   ```
+
+2. **读取 FPD 状态**:
+
+   ```tsx
+   const fpd = useSelector((state: RootState) => state.product.fpd);
+   const isSimulator = fpd === 'Simulator';
+   ```
+
+3. **使用 Badge 包裹按钮**:
+
+   ```tsx
+   <Badge count={isSimulator ? '模拟' : 0} offset={[-5, 5]} color="orange">
+     <Button ... />
+   </Badge>
+   ```
+
+4. **在 onClick 中判断环境**:
+
+   ```tsx
+   onClick={() => {
+     if (!isSimulator) {
+       console.warn('真实环境下,曝光操作需要通过硬件触发');
+       return;
+     }
+     triggerInspection();
+   }}
+   ```
+
+5. **更新 title 提示**:
+   ```tsx
+   title={`曝光指示器: ${exposureStatus}${isSimulator ? ' (模拟模式)' : ' (真实模式)'}`}
+   ```
+
+---
+
+## 7. 相关文件清单
+
+### 需要修改的文件
+
+| 文件路径                        | 修改内容                         |
+| ------------------------------- | -------------------------------- |
+| `src/states/productSlice.ts`    | 扩展 State 接口,添加 fpd 和 gen |
+| `src/pages/exam/DeviceArea.tsx` | 添加 Badge 组件和禁用逻辑        |
+
+### 相关参考文件
+
+| 文件路径                            | 作用                              |
+| ----------------------------------- | --------------------------------- |
+| `src/API/softwareInfo.ts`           | 定义 SoftwareInfo 接口和 API 调用 |
+| `src/states/store.ts`               | Redux store 配置                  |
+| `src/API/exam/triggerInspection.ts` | 曝光触发函数                      |
+
+---
+
+## 8. 注意事项
+
+### 8.1 初始化时序
+
+- `initializeProductState` 需要在应用启动时尽早调用
+- 确保在 DeviceArea 渲染前,fpd 状态已经加载完成
+- 可以在 `app.tsx` 或根组件中调用
+
+### 8.2 默认值处理
+
+- 在 API 未返回或加载失败时,fpd 默认为空字符串
+- 空字符串不等于 "Simulator",因此默认按钮是启用的
+- 如果需要更安全的默认行为,可以考虑:
+  ```typescript
+  const isSimulator = fpd === 'Simulator' || fpd === '';
+  ```
+
+### 8.3 样式兼容性
+
+- Badge 组件的 offset 可能需要根据实际按钮大小调整
+- 在不同屏幕尺寸下测试 Badge 的显示效果
+- 确保 Badge 不会遮挡按钮图标
+
+### 8.4 国际化
+
+- "模拟" 文字应该支持国际化
+- 可以使用 i18n 替换硬编码的文字:
+
+  ```tsx
+  import { useTranslation } from 'react-i18next';
+
+  const { t } = useTranslation();
+  <Badge count={isSimulator ? t('simulator') : 0} />;
+  ```
+
+---
+
+## 9. 未来扩展建议
+
+### 9.1 更多设备状态
+
+除了 FPD 和 GEN,未来可能需要显示更多设备状态:
+
+- 手闸(Hand Switch)状态
+- 脚踏板(Foot Switch)状态
+- 准直器(Collimator)状态
+
+建议在 productSlice 中统一管理这些设备信息。
+
+### 9.2 设备状态实时更新
+
+当前方案中,FPD 状态只在初始化时获取一次。如果设备可能在运行时切换模式,需要:
+
+1. 实现 WebSocket 或轮询机制监听设备状态变化
+2. 更新 Redux store 中的 fpd 状态
+3. UI 自动响应状态变化
+
+### 9.3 更丰富的视觉反馈
+
+- 在模拟模式下,可以为整个设备区域添加背景色
+- 在页面顶部显示全局的"模拟模式"横幅
+- 使用动画效果吸引用户注意
+
+### 9.4 用户偏好设置
+
+允许用户配置模拟模式下的行为:
+
+- 是否完全隐藏曝光按钮
+- 是否显示模拟曝光动画
+- 是否允许"假装曝光"用于培训目的
+
+---
+
+## 10. 参考资料
+
+- [Ant Design Badge 组件文档](https://ant.design/components/badge-cn)
+- [Redux Toolkit 官方文档](https://redux-toolkit.js.org/)
+- [React useSelector Hook](https://react-redux.js.org/api/hooks#useselector)
+
+---
+
+## 更新记录
+
+| 日期      | 修改人 | 修改内容                     |
+| --------- | ------ | ---------------------------- |
+| 2025/10/7 | -      | 创建文档,定义需求和实现方案 |

+ 40 - 25
src/pages/exam/DeviceArea.tsx

@@ -1,4 +1,4 @@
-import { Flex, Button } from 'antd';
+import { Flex, Button, Badge } from 'antd';
 import {
   ToolOutlined,
   CameraOutlined,
@@ -25,6 +25,11 @@ const DeviceArea = ({ className }: { className?: string }) => {
   const tabletStatus = useSelector(
     (state: RootState) => state.deviceArea.tabletStatus
   );
+
+  // 读取 FPD 状态,判断是否为模拟器模式
+  const fpd = useSelector((state: RootState) => state.product.fpd);
+  const isSimulator = fpd === 'Simulator';
+
   const btnStyle = { width: '1.5rem', height: '1.5rem' };
   const classValue = 'mr-1';
 
@@ -48,30 +53,40 @@ const DeviceArea = ({ className }: { className?: string }) => {
         }
         title={`手闸状态指示器: ${generatorStatus}`}
       />
-      <Button
-        style={btnStyle}
-        data-testid="device-all-ready"
-        className={`${classValue} ${
-          exposureStatus === 'ready'
-            ? 'text-green-500'
-            : exposureStatus === 'not_ready'
-              ? ''
-              : ''
-        }`}
-        icon={
-          <CameraOutlined
-          // className={
-          //   exposureStatus === 'ready'
-          //     ? 'text-green-500'
-          //     : exposureStatus === 'not_ready'
-          //       ? ''
-          //       : ''
-          // }
-          />
-        }
-        onClick={triggerInspection}
-        title={`曝光指示器: ${exposureStatus}`}
-      />
+      <Badge count={isSimulator ? 'S' : 'R'} offset={[-15, 10]} color="orange">
+        <Button
+          style={btnStyle}
+          data-testid="device-all-ready"
+          className={`${classValue} ${
+            exposureStatus === 'ready'
+              ? 'text-green-500'
+              : exposureStatus === 'not_ready'
+                ? ''
+                : ''
+          }`}
+          icon={
+            <CameraOutlined
+            // className={
+            //   exposureStatus === 'ready'
+            //     ? 'text-green-500'
+            //     : exposureStatus === 'not_ready'
+            //       ? ''
+            //       : ''
+            // }
+            />
+          }
+          onClick={() => {
+            if (!isSimulator) {
+              // 真实环境下禁止点击
+              console.warn('真实环境下,曝光操作需要通过硬件触发');
+              return;
+            }
+            // 模拟环境下允许点击
+            triggerInspection();
+          }}
+          title={`曝光指示器: ${exposureStatus}${isSimulator ? ' (模拟模式)' : ' (真实模式)'}`}
+        />
+      </Badge>
       <Button
         style={btnStyle}
         className={`${classValue} ${

+ 10 - 0
src/states/productSlice.ts

@@ -6,6 +6,8 @@ interface ProductState {
   language: string;
   source: 'Electron' | 'Browser' | 'Android';
   guest: string; //本质是token,只用于急诊情况
+  fpd: string; // 平板探测器类型: "Simulator" 或 "Physics"
+  gen: string; // 发生器类型: "Simulator" 或 "Physics"
 }
 
 const initialState: ProductState = {
@@ -13,6 +15,8 @@ const initialState: ProductState = {
   language: 'en',
   source: 'Browser',
   guest: '',
+  fpd: '',
+  gen: '',
 };
 
 export const initializeProductState = createAsyncThunk(
@@ -25,6 +29,8 @@ export const initializeProductState = createAsyncThunk(
       language: softwareInfo.language[0],
       source: 'Browser' as const,
       guest: softwareInfo.guest,
+      fpd: softwareInfo.FPD,
+      gen: softwareInfo.GEN,
     };
   }
 );
@@ -38,6 +44,8 @@ const productSlice = createSlice({
       state.language = action.payload.language;
       state.source = action.payload.source;
       state.guest = action.payload.guest;
+      state.fpd = action.payload.fpd;
+      state.gen = action.payload.gen;
     },
   },
   extraReducers: (builder) => {
@@ -47,6 +55,8 @@ const productSlice = createSlice({
         state.language = action.payload.language;
         state.source = action.payload.source;
         state.guest = action.payload.guest;
+        state.fpd = action.payload.fpd;
+        state.gen = action.payload.gen;
       })
       .addCase(initializeProductState.rejected, (state, action) => {
         console.error(