模拟状态的呈现效果.md 15 KB

模拟状态的呈现效果

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 接口中添加 fpdgen 字段
    • 更新 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 角标(最终采用)✅

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)

接口定义

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 接口

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

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

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

完整代码

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 组件
   import { Flex, Button, Badge } from 'antd';
  1. 读取 FPD 状态
   const fpd = useSelector((state: RootState) => state.product.fpd);
   const isSimulator = fpd === 'Simulator';
  1. 使用 Badge 包裹按钮
   <Badge count={isSimulator ? '模拟' : 0} offset={[-5, 5]} color="orange">
     <Button ... />
   </Badge>
  1. 在 onClick 中判断环境
   onClick={() => {
     if (!isSimulator) {
       console.warn('真实环境下,曝光操作需要通过硬件触发');
       return;
     }
     triggerInspection();
   }}
  1. 更新 title 提示

    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 替换硬编码的文字:
  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. 参考资料


更新记录

日期 修改人 修改内容
2025/10/7 - 创建文档,定义需求和实现方案