急诊拍照功能.md 40 KB

急诊拍照功能实现方案

一、功能概述

应用场景

用户急诊进入检查,给患者拍照,避免出现患者离开后急诊得到的 study 对应不上哪个患者。

业务流程

用户点击按钮 → 打开摄像头 → 拍照 → 预览确认 → 发送到服务器

UI 设计

  • 入口按钮:位于 ContentAreaLarge 组件的工具栏区域
  • 摄像界面:弹出式 Modal 显示
  • 界面元素
    • 实时摄像头画面显示区域
    • 拍照按钮
    • 预览确认区域
    • 重拍/发送按钮

二、参与者列表(从粗到细)

1. UI 层

主要组件

  • ContentAreaLarge.tsx

    • 功能:入口组件,包含触发拍照的按钮
    • 职责:触发打开摄像头模态框
  • CameraModal.tsx (新建)

    • 功能:摄像头模态框主组件
    • 职责:
    • 管理模态框的显示/隐藏
    • 协调子组件的切换(预览 ↔ 拍照)
    • 处理摄像头生命周期
  • CameraPreview.tsx (新建)

    • 功能:摄像头实时预览子组件
    • 职责:
    • 显示实时摄像头画面
    • 提供拍照按钮
    • 调用拍照功能
  • PhotoPreview.tsx (新建)

    • 功能:拍照后预览组件
    • 职责:
    • 显示已拍摄的照片
    • 提供重拍/发送按钮
    • 处理照片发送

2. State 管理层

Redux Slice

  • cameraSlice.ts (新建)

    • 位置:src/states/exam/cameraSlice.ts
    • State 结构:

      interface CameraState {
      isOpen: boolean;                    // 摄像头是否开启
      stream: MediaStream | null;         // 媒体流对象
      capturedImage: string | null;       // 捕获的图片(base64)
      isLoading: boolean;                 // 加载状态
      error: string | null;               // 错误信息
      currentStudyInstanceUid: string | null;  // 当前 Study UID
      }
      
      • Actions:
      • openCamera() - 打开摄像头
      • closeCamera() - 关闭摄像头
      • setStream() - 设置媒体流
      • setCapturedImage() - 设置捕获的图片
      • setLoading() - 设置加载状态
      • setError() - 设置错误信息
      • resetState() - 重置状态

      3. API 层

      API 操作

      • cameraActions.ts (新建)
      • 位置:src/API/patient/cameraActions.ts
      • 方法:
      • uploadPatientPhoto(photo: string, studyInstanceUid: string): Promise<UploadPhotoResponse>
      • 上传患者照片到后端
      • 关联照片到当前 Study
      • getPatientPhoto(studyInstanceUid: string): Promise<string>
      • 获取已保存的患者照片
      • deletePatientPhoto(photoId: string): Promise<void>
      • 删除患者照片

      4. Service 层(适配器模式)

      抽象层

      • CameraService.ts (新建)
      • 位置:src/services/camera/CameraService.ts
      • 接口定义: typescript interface ICameraService { requestPermission(): Promise<boolean>; getMediaStream(constraints?: MediaStreamConstraints): Promise<MediaStream>; capturePhoto(stream: MediaStream): Promise<string>; stopStream(stream: MediaStream): void; }

具体实现

  • BrowserCameraService.ts (新建)

    • 位置:src/services/camera/BrowserCameraService.ts
    • 实现:浏览器环境下的摄像头访问
    • 技术:navigator.mediaDevices.getUserMedia()
  • ElectronCameraService.ts (新建)

    • 位置:src/services/camera/ElectronCameraService.ts
    • 实现:Electron 环境下的摄像头访问
    • 技术:Electron 特定 API + 窗口权限配置
  • CordovaCameraService.ts (新建)

    • 位置:src/services/camera/CordovaCameraService.ts
    • 实现:Cordova 环境下的摄像头访问
    • 技术:Cordova Camera Plugin
  • CameraServiceFactory.ts (新建)

    • 位置:src/services/camera/CameraServiceFactory.ts
    • 职责:根据运行环境创建对应的 CameraService 实例
    • 逻辑:

      class CameraServiceFactory {
      static create(): ICameraService {
      if (window.cordova) {
        return new CordovaCameraService();
      } else if (window.electronAPI) {
        return new ElectronCameraService();
      } else {
        return new BrowserCameraService();
      }
      }
      }
      

      5. Domain 层

      领域模型

      • patientPhoto.ts (新建)
      • 位置:src/domain/patientPhoto.ts
      • 模型定义: ```typescript interface PatientPhoto { id: string; studyInstanceUid: string; patientId: string; photoData: string; // base64 timestamp: Date; metadata: PhotoMetadata; }

      interface PhotoMetadata { width: number; height: number; format: string; size: number; deviceInfo?: string; } ```

6. Utils 层

工具函数

  • imageUtils.ts (新建)
    • 位置:src/utils/imageUtils.ts
    • 方法:
    • base64ToBlob(base64: string): Blob - base64 转换为 Blob
    • compressImage(base64: string, quality: number): Promise<string> - 图片压缩
    • resizeImage(base64: string, maxWidth: number, maxHeight: number): Promise<string> - 图片缩放
    • getImageDimensions(base64: string): Promise<{width: number, height: number}> - 获取图片尺寸
    • validateImageSize(base64: string, maxSize: number): boolean - 验证图片大小

三、交互流程(泳道图)

用户                ContentAreaLarge        CameraModal         CameraService       Redux Store        后端API
│                        │                      │                    │                   │                │
│──点击摄像头按钮────────>│                      │                    │                   │                │
│                        │─dispatch(openCamera)>│                    │                   │                │
│                        │                      │                    │───────────────────>│                │
│                        │                      │                    │  setIsOpen(true)  │                │
│                        │                      │<────显示Modal──────│                   │                │
│                        │                      │                    │                   │                │
│                        │                      │─requestPermission()>│                   │                │
│                        │                      │<─────权限结果──────│                   │                │
│                        │                      │                    │                   │                │
│                        │                      │─getMediaStream()──>│                   │                │
│                        │                      │                    │─getUserMedia()───>│                │
│                        │                      │                    │ (navigator.mediaDevices)           │
│                        │                      │<─返回MediaStream───│                   │                │
│                        │                      │─dispatch(setStream)>│                  │────────────────>│
│                        │                      │                    │                   │  stream saved  │
│<─────看到实时视频──────────────────────────────│                    │                   │                │
│                        │                      │                    │                   │                │
│──点击拍照按钮────────────────────────────────>│                    │                   │                │
│                        │                      │─capturePhoto()────>│                   │                │
│                        │                      │                    │─canvas操作────────>│                │
│                        │                      │                    │─toDataURL()───────>│                │
│                        │                      │<─返回base64────────│                   │                │
│                        │                      │─dispatch(setCapturedImage)>│           │────────────────>│
│                        │                      │                    │                   │ image saved    │
│<─────看到预览照片──────────────────────────────│                    │                   │                │
│                        │                      │                    │                   │                │
│──点击发送按钮────────────────────────────────>│                    │                   │                │
│                        │                      │─dispatch(setLoading)>│                 │────────────────>│
│                        │                      │                    │                   │  loading=true  │
│                        │                      │─uploadPhoto()──────────────────────────────────────────>│
│                        │                      │                    │                   │                │─保存照片
│                        │                      │                    │                   │                │─关联Study
│                        │                      │<──────────────────────────────────────────────────────OK│
│                        │                      │─dispatch(closeCamera)>│                │────────────────>│
│                        │                      │                    │                   │  reset state   │
│                        │                      │─stopStream()──────>│                   │                │
│                        │                      │                    │─停止MediaStream──>│                │
│<─────提示成功────────────────────────────────│                    │                   │                │
│                        │                      │─关闭Modal──────────│                   │                │

异常流程

用户                CameraModal         CameraService       Redux Store
│                      │                    │                   │
│──点击摄像头按钮────>│                    │                   │
│                      │─requestPermission()>│                  │
│                      │<─────拒绝──────────│                   │
│                      │─dispatch(setError)>│                   │───────────>
│<─显示错误提示────────│                    │                   │  error set
│                      │                    │                   │
│──点击重试/取消──────>│                    │                   │
│                      │─关闭Modal或重试────│                   │

四、数据流

正向数据流(拍照发送)

用户操作
    ↓
UI 事件触发 (点击摄像头按钮)
    ↓
Redux Action (dispatch openCamera)
    ↓
Redux State 更新 (isOpen: true)
    ↓
CameraModal 组件渲染
    ↓
useEffect 监听 isOpen 变化
    ↓
调用 CameraServiceFactory.create().getMediaStream()
    ↓
根据环境选择对应的 Service
    ├─ 浏览器: BrowserCameraService
    ├─ Electron: ElectronCameraService
    └─ Cordova: CordovaCameraService
    ↓
获取 MediaStream
    ↓
设置到 <video> 元素的 srcObject
    ↓
Redux State 更新 (stream: MediaStream)
    ↓
用户看到实时画面
    ↓
用户点击拍照
    ↓
Canvas 捕获当前帧
    ├─ canvas.drawImage(video, 0, 0, width, height)
    └─ canvas.toDataURL('image/jpeg', quality)
    ↓
获得 base64 字符串
    ↓
可选:图片压缩/调整尺寸
    ↓
Redux State 更新 (capturedImage: base64)
    ↓
切换到 PhotoPreview 组件
    ↓
用户确认并点击发送
    ↓
调用 API: uploadPatientPhoto(base64, studyInstanceUid)
    ↓
HTTP POST 请求到后端
    ↓
后端保存照片并关联到 Study
    ↓
返回成功响应
    ↓
Redux State 重置
    ↓
停止 MediaStream
    ↓
关闭 Modal
    ↓
显示成功提示

错误处理数据流

任意步骤发生错误
    ↓
捕获异常
    ↓
dispatch setError(errorMessage)
    ↓
Redux State 更新 (error: string)
    ↓
UI 显示错误信息
    ↓
提供重试或取消选项
    ↓
用户选择
    ├─ 重试: 清除错误,重新执行
    └─ 取消: 关闭 Modal,重置状态

五、核心数据结构

1. Redux State (cameraSlice)

interface CameraState {
  // 摄像头开启状态
  isOpen: boolean;
  
  // 媒体流对象(用于显示和拍照)
  stream: MediaStream | null;
  
  // 捕获的图片(base64 格式)
  capturedImage: string | null;
  
  // 加载状态(用于上传时显示 loading)
  isLoading: boolean;
  
  // 错误信息
  error: string | null;
  
  // 当前 Study Instance UID(用于关联照片)
  currentStudyInstanceUid: string | null;
}

// 初始状态
const initialState: CameraState = {
  isOpen: false,
  stream: null,
  capturedImage: null,
  isLoading: false,
  error: null,
  currentStudyInstanceUid: null,
};

2. 患者照片领域模型

/**
 * 患者照片实体
 */
interface PatientPhoto {
  // 照片唯一标识
  id: string;
  
  // 关联的 Study Instance UID
  studyInstanceUid: string;
  
  // 患者ID
  patientId: string;
  
  // 照片数据(base64)
  photoData: string;
  
  // 拍摄时间
  timestamp: Date;
  
  // 照片元数据
  metadata: PhotoMetadata;
}

/**
 * 照片元数据
 */
interface PhotoMetadata {
  // 图片宽度
  width: number;
  
  // 图片高度
  height: number;
  
  // 图片格式(如 'image/jpeg')
  format: string;
  
  // 文件大小(字节)
  size: number;
  
  // 设备信息(可选)
  deviceInfo?: string;
}

3. API 请求/响应类型

/**
 * 上传照片请求
 */
interface UploadPhotoRequest {
  // Study Instance UID
  study_instance_uid: string;
  
  // 照片数据(base64)
  photo_data: string;
  
  // 时间戳
  timestamp: string;
  
  // 元数据(可选)
  metadata?: {
    width: number;
    height: number;
    format: string;
    size: number;
  };
}

/**
 * 上传照片响应
 */
interface UploadPhotoResponse {
  // 响应码
  code: string;
  
  // 描述信息
  description: string;
  
  // 数据
  data: {
    // 照片ID
    photo_id: string;
    
    // 照片URL(如果后端返回)
    url?: string;
    
    // 其他信息
    [key: string]: any;
  };
}

4. CameraService 接口

/**
 * 摄像头服务接口
 */
interface ICameraService {
  /**
   * 请求摄像头权限
   * @returns 是否成功获取权限
   */
  requestPermission(): Promise<boolean>;
  
  /**
   * 获取媒体流
   * @param constraints 媒体约束条件
   * @returns MediaStream 对象
   */
  getMediaStream(constraints?: MediaStreamConstraints): Promise<MediaStream>;
  
  /**
   * 从媒体流中捕获照片
   * @param stream 媒体流对象
   * @returns base64 格式的图片数据
   */
  capturePhoto(stream: MediaStream): Promise<string>;
  
  /**
   * 停止媒体流
   * @param stream 要停止的媒体流
   */
  stopStream(stream: MediaStream): void;
}

/**
 * 媒体流约束条件
 */
interface MediaStreamConstraints {
  video?: boolean | {
    width?: { ideal: number };
    height?: { ideal: number };
    facingMode?: 'user' | 'environment';
  };
  audio?: boolean;
}

5. 组件 Props 类型

/**
 * CameraModal Props
 */
interface CameraModalProps {
  // 是否显示
  visible: boolean;
  
  // 关闭回调
  onClose: () => void;
  
  // Study Instance UID
  studyInstanceUid: string;
}

/**
 * CameraPreview Props
 */
interface CameraPreviewProps {
  // 媒体流
  stream: MediaStream | null;
  
  // 拍照回调
  onCapture: (imageData: string) => void;
  
  // 取消回调
  onCancel: () => void;
}

/**
 * PhotoPreview Props
 */
interface PhotoPreviewProps {
  // 图片数据(base64)
  imageData: string;
  
  // 重拍回调
  onRetake: () => void;
  
  // 发送回调
  onSend: () => void;
  
  // 加载状态
  isLoading: boolean;
}

六、执行流程(从用户操作开始)

流程起点:用户点击"打开/关闭摄像头"按钮

阶段 1:初始化

  1. 用户操作

    • 用户在检查页面(ContentAreaLarge)点击摄像头图标
    • 触发 handleOpenCamera 事件处理函数
  2. 状态初始化

    • 获取当前 studyInstanceUid
    • dispatch openCamera(studyInstanceUid) action
    • Redux state 更新:

      {
      isOpen: true,
      currentStudyInstanceUid: 'xxx',
      error: null
      }
      
      1. 组件渲染
      2. CameraModal 组件挂载
      3. useEffect 监听 isOpen 状态变化
      4. 开始摄像头初始化流程

      阶段 2:获取摄像头权限

      1. 环境检测
      2. CameraServiceFactory 检测运行环境
      3. 创建对应的 CameraService 实例

      4. 权限请求

      5. 调用 cameraService.requestPermission()

      6. 浏览器环境:

        await navigator.mediaDevices.getUserMedia({ video: true })
        
    • 如果用户拒绝:

      • dispatch setError('用户拒绝了摄像头权限')
      • 显示错误提示
      • 提供重试或关闭选项
    • 如果用户同意:继续下一步

阶段 3:启动摄像头

  1. 获取媒体流

    • 调用 cameraService.getMediaStream()
    • 配置约束条件:

      const constraints = {
      video: {
       width: { ideal: 1280 },
       height: { ideal: 720 },
       facingMode: 'user'
      }
      }
      
      1. 设置视频源
      2. 将 MediaStream 设置到 <video> 元素
      3. dispatch setStream(stream)
      4. video 元素开始播放

      5. 显示预览界面

      6. CameraPreview 组件渲染

      7. 显示实时摄像头画面

      8. 显示拍照按钮

      阶段 4:实时预览

      1. 用户调整
      2. 用户可以看到自己的实时画面
      3. 可以调整位置、角度
      4. 可以选择取消或拍照

      阶段 5:拍照

      1. 捕获画面
      2. 用户点击"拍照"按钮
      3. 创建 Canvas 元素: typescript const canvas = document.createElement('canvas'); const video = videoRef.current; canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0);
  2. 转换为 base64

    • 使用 canvas.toDataURL() 转换
    • 可选择压缩质量(如 0.8)

      const base64 = canvas.toDataURL('image/jpeg', 0.8);
      
      1. 可选:图片处理
      2. 如果图片过大,进行压缩
      3. 如果尺寸不合适,进行缩放
      4. 调用 imageUtils.compressImage()resizeImage()

      5. 更新状态

      6. dispatch setCapturedImage(base64)

      7. 切换到 PhotoPreview 组件

      阶段 6:预览确认

      1. 显示预览
      2. PhotoPreview 组件渲染
      3. 显示刚拍摄的照片
      4. 提供"重拍"和"发送"按钮

      5. 用户决策

      6. 选择重拍

      7. dispatch setCapturedImage(null)

      8. 返回 CameraPreview 界面

      9. 继续显示实时画面

      10. 选择发送:继续下一步

      阶段 7:上传照片

      1. 开始上传
      2. dispatch setLoading(true)
      3. 显示加载指示器

      4. 调用 API

      5. 调用 uploadPatientPhoto(base64, studyInstanceUid)

      6. 发送 HTTP POST 请求:

        POST /api/patient/photo
        {
        study_instance_uid: 'xxx',
        photo_data: 'data:image/jpeg;base64,xxx',
        timestamp: '2025-01-11T10:00:00Z',
        metadata: {
        width: 1280,
        height: 720,
        format: 'image/jpeg',
        size: 123456
        }
        }
        
  3. 后端处理

    • 后端接收照片
    • 保存到存储系统
    • 在数据库中创建关联记录
    • 返回成功响应
  4. 处理响应

    • 如果成功:
      • dispatch setLoading(false)
      • 显示成功消息
      • 继续清理流程
    • 如果失败:
      • dispatch setError(errorMessage)
      • 显示错误信息
      • 提供重试选项

阶段 8:清理和关闭

  1. 停止媒体流

    • 调用 cameraService.stopStream(stream)
    • 释放摄像头资源:

      stream.getTracks().forEach(track => track.stop());
      
      1. 重置状态
      2. dispatch resetState()
      3. Redux state 恢复到初始值

      4. 关闭 Modal

      5. dispatch closeCamera()

      6. CameraModal 组件卸载

      7. 显示结果

      8. 在主界面显示成功提示

      9. 用户可以继续其他操作


      七、端口适配器模式设计

      为什么需要适配器模式?

      应用需要运行在三个不同环境,每个环境访问摄像头的方式不同:

      环境 访问方式 特点
      浏览器 navigator.mediaDevices.getUserMedia() 标准 Web API
      Electron Electron API + 窗口权限 需要特殊配置
      Cordova Cordova Camera Plugin 移动端原生插件

      适配器架构

      ```

┌─────────────────────────────────────────┐ │ CameraModal (组件层) │ │ 统一调用接口 │ └──────────────┬──────────────────────────┘

           │
           ↓

┌─────────────────────────────────────────┐ │ CameraServiceFactory (工厂层) │ │ 根据环境创建对应的 Service │ └──────────────┬──────────────────────────┘

           │
  ┌────────┴────────┬────────────────┐
  ↓                 ↓                ↓

┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ Browser │ │ Electron │ │ Cordova │ │ Camera │ │ Camera │ │ Camera │ │ Service │ │ Service │ │ Service │ └────┬─────┘ └──────┬───────┘ └──────┬───────┘

 │                │                  │
 ↓                ↓                  ↓

┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ Web API │ │ Electron API │ │Cordova Plugin│ └──────────┘ └──────────────┘ └──────────────┘


### 接口定义

```typescript
/**
 * 摄像头服务抽象接口
 */
interface ICameraService {
  /**
   * 请求摄像头权限
   */
  requestPermission(): Promise<boolean>;
  
  /**
   * 获取媒体流
   */
  getMediaStream(constraints?: MediaStreamConstraints): Promise<MediaStream>;
  
  /**
   * 捕获照片
   */
  capturePhoto(stream: MediaStream): Promise<string>;
  
  /**
   * 停止媒体流
   */
  stopStream(stream: MediaStream): void;
}

具体实现

1. 浏览器实现

class BrowserCameraService implements ICameraService {
  async requestPermission(): Promise<boolean> {
    try {
      // 先请求权限
      const stream = await navigator.mediaDevices.getUserMedia({ 
        video: true 
      });
      // 立即停止,只是为了获取权限
      stream.getTracks().forEach(track => track.stop());
      return true;
    } catch (error) {
      console.error('摄像头权限被拒绝:', error);
      return false;
    }
  }
  
  async getMediaStream(
    constraints: MediaStreamConstraints = { video: true }
  ): Promise<MediaStream> {
    try {
      const stream = await navigator.mediaDevices.getUserMedia(constraints);
      return stream;
    } catch (error) {
      throw new Error(`获取摄像头失败: ${error.message}`);
    }
  }
  
  async capturePhoto(stream: MediaStream): Promise<string> {
    return new Promise((resolve, reject) => {
      try {
        // 创建 video 元素
        const video = document.createElement('video');
        video.srcObject = stream;
        video.play();
        
        video.onloadedmetadata = () => {
          // 创建 canvas
          const canvas = document.createElement('canvas');
          canvas.width = video.videoWidth;
          canvas.height = video.videoHeight;
          
          // 绘制当前帧
          const ctx = canvas.getContext('2d');
          if (!ctx) {
            reject(new Error('无法获取 Canvas 2D Context'));
            return;
          }
          
          ctx.drawImage(video, 0, 0);
          
          // 转换为 base64
          const base64 = canvas.toDataURL('image/jpeg', 0.8);
          resolve(base64);
        };
        
        video.onerror = () => {
          reject(new Error('视频加载失败'));
        };
      } catch (error) {
        reject(error);
      }
    });
  }
  
  stopStream(stream: MediaStream): void {
    stream.getTracks().forEach(track => track.stop());
  }
}

2. Electron 实现

class ElectronCameraService implements ICameraService {
  async requestPermission(): Promise<boolean> {
    // Electron 环境下的权限处理
    // 可能需要在主进程中配置
    try {
      // 检查是否有摄像头访问权限
      const hasPermission = await window.electronAPI.checkCameraPermission();
      if (!hasPermission) {
        // 请求权限
        const granted = await window.electronAPI.requestCameraPermission();
        return granted;
      }
      return true;
    } catch (error) {
      console.error('Electron 摄像头权限请求失败:', error);
      return false;
    }
  }
  
  async getMediaStream(
    constraints: MediaStreamConstraints = { video: true }
  ): Promise<MediaStream> {
    // Electron 可以使用标准的 getUserMedia
    try {
      const stream = await navigator.mediaDevices.getUserMedia(constraints);
      return stream;
    } catch (error) {
      throw new Error(`Electron 获取摄像头失败: ${error.message}`);
    }
  }
  
  async capturePhoto(stream: MediaStream): Promise<string> {
    // 与浏览器实现相同
    return new BrowserCameraService().capturePhoto(stream);
  }
  
  stopStream(stream: MediaStream): void {
    stream.getTracks().forEach(track => track.stop());
  }
}

3. Cordova 实现

class CordovaCameraService implements ICameraService {
  async requestPermission(): Promise<boolean> {
    // Cordova 插件通常在第一次使用时自动请求权限
    return true;
  }
  
  async getMediaStream(
    constraints?: MediaStreamConstraints
  ): Promise<MediaStream> {
    // Cordova 不使用 MediaStream,而是使用插件 API
    // 这里返回一个占位符
    throw new Error('Cordova 环境不支持 MediaStream');
  }
  
  async capturePhoto(): Promise<string> {
    return new Promise((resolve, reject) => {
      if (!window.navigator.camera) {
        reject(new Error('Cordova Camera 插件未安装'));
        return;
      }
      
      const options = {
        quality: 80,
        destinationType: Camera.DestinationType.DATA_URL,
        sourceType: Camera.PictureSourceType.CAMERA,
        encodingType: Camera.EncodingType.JPEG,
        mediaType: Camera.MediaType.PICTURE,
        correctOrientation: true,
        targetWidth: 1280,
        targetHeight: 720,
      };
      
      window.navigator.camera.getPicture(
        (imageData) => {
          // 添加 base64 前缀
          resolve(`data:image/jpeg;base64,${imageData}`);
        },
        (error) => {
          reject(new Error(`拍照失败: ${error}`));
        },
        options
      );
    });
  }
  
  stopStream(): void {
    // Cordova 不需要停止流
  }
}

4. 工厂类实现

class CameraServiceFactory {
  private static instance: ICameraService | null = null;
  
  static create(): ICameraService {
    if (this.instance) {
      return this.instance;
    }
    
    // 检测 Cordova 环境
    if (window.cordova) {
      console.log('检测到 Cordova 环境,使用 CordovaCameraService');
      this.instance = new CordovaCameraService();
    }
    // 检测 Electron 环境
    else if (window.electronAPI) {
      console.log('检测到 Electron 环境,使用 ElectronCameraService');
      this.instance = new ElectronCameraService();
    }
    // 浏览器环境
    else {
      console.log('检测到浏览器环境,使用 BrowserCameraService');
      this.instance = new BrowserCameraService();
    }
    
    return this.instance;
  }
  
  static reset(): void {
    this.instance = null;
  }
}

八、关键问题和修复方案

问题1:video 元素被条件渲染导致 videoRef.current 为 null

问题描述

在初始实现中,video 元素和 loading 状态使用了条件渲染:

{/* ❌ 错误的实现 */}
{isInitializing && <Spin tip="正在初始化摄像头..." />}
{!isInitializing && <video ref={videoRef} ... />}

问题流程

  1. useEffect 检查 videoRef.current 存在 → 调用 initCamera()
  2. initCamera() 第一行设置 setIsInitializing(true)
  3. React 重新渲染,!isInitializing 变为 false
  4. video 元素被卸载,videoRef.current 变成 null
  5. 后续代码 if (videoRef.current) 失败,无法绑定事件

解决方案

使用遮罩层而不是条件渲染,确保 video 元素始终存在:

{/* ✅ 正确的实现 */}
{!capturedImage && (
  <div style={{ position: 'relative' }}>
    {/* video 元素始终存在,只是通过 display 控制可见性 */}
    <video
      ref={videoRef}
      autoPlay
      playsInline
      muted
      style={{
        width: '100%',
        maxHeight: '500px',
        backgroundColor: '#000',
        borderRadius: 8,
        display: isInitializing ? 'none' : 'block',  // 隐藏但不卸载
      }}
    />
    
    {/* loading 作为遮罩层覆盖在 video 上方 */}
    {isInitializing && (
      <div
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          width: '100%',
          height: '500px',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          backgroundColor: '#000',
          borderRadius: 8,
        }}
      >
        <Spin size="large" tip="正在初始化摄像头..." />
      </div>
    )}
    
    {/* 按钮等其他元素 */}
    {!isInitializing && (
      <Space style={{ marginTop: 24 }} size="large">
        <Button ... >拍照</Button>
        <Button ... >取消</Button>
      </Space>
    )}
  </div>
)}

关键改进

  1. video 元素生命周期独立

    • 只要 !capturedImage,video 就存在
    • 不受 isInitializing 状态影响
    • videoRef.current 永远不会因为状态变化而变成 null
  2. 使用 CSS display 控制可见性

    • display: none 隐藏元素但不卸载
    • DOM 节点仍然存在,ref 仍然有效
  3. loading 变成遮罩层

    • 使用绝对定位覆盖在 video 上方
    • 不影响 video 的挂载状态
    • 更好的视觉效果

为什么重拍后能正常显示?

因为重拍时:

  1. setCapturedImage(null) 触发 useEffect
  2. 此时 isInitializing 已经是 false
  3. video 元素已经挂载并且可见
  4. 然后才调用 initCamera()
  5. 所以 videoRef.current 有效

这个问题说明了状态更新时机的重要性:在 React 中,状态更新会触发重新渲染,如果组件结构依赖于状态,可能导致预期之外的 DOM 变化。


问题2:事件绑定时机错误导致 onloadedmetadata 不触发

问题描述

初始代码先设置 srcObject,后绑定 onloadedmetadata 事件:

// ❌ 错误的顺序
videoRef.current.srcObject = stream;
videoRef.current.onloadedmetadata = () => { ... };

如果流设置得太快,元数据加载可能在事件绑定前就完成了,导致事件永远不触发,视频无法播放。

解决方案

必须先绑定事件,再设置 srcObject:

// ✅ 正确的顺序
if (videoRef.current) {
  // 1. 先绑定事件
  videoRef.current.onloadedmetadata = async () => {
    try {
      if (videoRef.current) {
        await videoRef.current.play();
        console.log('[CameraModal] 视频开始播放');
      }
    } catch (playError: any) {
      console.error('[CameraModal] 视频播放失败:', playError);
      message.error('视频播放失败: ' + playError.message);
    }
  };
  
  // 2. 再设置流
  videoRef.current.srcObject = stream;
}

原理说明

  • srcObject 设置后会立即开始加载媒体
  • 如果媒体加载很快,loadedmetadata 事件可能在绑定前就触发
  • 事件触发后不会重复触发,导致回调永远不执行

问题3:handleRetake 重复初始化

问题描述

handleRetake 直接调用 initCamera(),同时 setCapturedImage(null) 也会触发 useEffect 中的初始化:

// ❌ 导致重复初始化
const handleRetake = () => {
  dispatch(setCapturedImage(null));  // 触发 useEffect
  initCamera();                      // 直接调用,导致重复
};

解决方案

移除直接调用,让 useEffect 自动处理:

// ✅ 避免重复初始化
const handleRetake = () => {
  dispatch(setCapturedImage(null));
  // 不需要手动调用 initCamera(),useEffect 会自动处理
};

useEffect 的依赖包含 capturedImage,当它变为 null 时会自动触发重新初始化。


九、技术要点和注意事项

1. 安全性

HTTPS 要求

  • 摄像头访问必须在 HTTPS 环境或 localhost 下
  • 生产环境必须配置 SSL 证书
  • 开发环境可以使用 localhost

权限管理

  • 必须获得用户明确授权
  • 权限被拒绝时提供友好的错误提示
  • 提供重新请求权限的入口

数据传输

  • 照片数据应该加密传输
  • 使用 HTTPS 协议
  • 考虑对敏感照片添加水印或加密

2. 性能优化

图片压缩

// 压缩策略
const COMPRESSION_CONFIG = {
  quality: 0.8,           // JPEG 压缩质量
  maxWidth: 1280,         // 最大宽度
  maxHeight: 720,         // 最大高度
  maxSize: 1024 * 1024,   // 最大文件大小 1MB
};

资源管理

  • 及时释放 MediaStream
  • 关闭 Modal 时立即停止摄像头
  • 避免内存泄漏

延迟加载

  • 组件懒加载
  • 只在需要时加载摄像头相关代码

3. 用户体验

加载状态

  • 显示摄像头初始化进度
  • 上传时显示 loading 动画
  • 提供操作反馈

错误处理

  • 权限被拒绝:提示用户如何开启权限
  • 摄像头不可用:提供文件上传作为备选
  • 网络错误:提供重试机制

交互优化

  • 支持重拍功能
  • 照片预览清晰
  • 提供放大查看
  • 快捷键支持(ESC 关闭)

4. 兼容性

浏览器支持

  • Chrome 53+
  • Firefox 36+
  • Safari 11+
  • Edge 12+

移动端适配

  • 响应式布局
  • 触摸友好的按钮尺寸
  • 横竖屏适配

降级方案

// 检测浏览器支持
const isCameraSupported = !!(
  navigator.mediaDevices && 
  navigator.mediaDevices.getUserMedia
);

// 不支持时提供文件上传
if (!isCameraSupported) {
  return <FileUpload />;
}

5. 测试要点

单元测试

  • CameraService 各个方法
  • Redux actions 和 reducers
  • 工具函数

集成测试

  • 完整的拍照上传流程
  • 错误处理流程
  • 权限请求流程

E2E 测试

  • 用户完整操作流程
  • 各种异常情况
  • 跨浏览器测试

九、实施步骤

第一阶段:基础架构(1-2天)

  1. 创建 Redux Slice

    • 定义 state 结构
    • 实现 actions 和 reducers
    • 编写单元测试
  2. 实现适配器模式

    • 定义 ICameraService 接口
    • 实现 BrowserCameraService
    • 实现 CameraServiceFactory
    • 编写单元测试
  3. 创建工具函数

    • 实现图片压缩
    • 实现 base64 转换
    • 编写单元测试

第二阶段:UI 组件(2-3天)

  1. 实现 CameraModal

    • 基本结构和布局
    • 生命周期管理
    • 状态切换逻辑
  2. 实现 CameraPreview

    • 视频预览
    • 拍照功能
    • UI 交互
  3. 实现 PhotoPreview

    • 照片显示
    • 重拍/发送功能
    • 加载状态

第三阶段:API 集成(1天)

  1. 实现 API 接口

    • 上传照片接口
    • 错误处理
    • 请求重试
  2. 集成到主流程

    • 修改 ContentAreaLarge
    • 添加按钮事件
    • 测试完整流程

第四阶段:环境适配(2-3天)

  1. Electron 适配

    • 实现 ElectronCameraService
    • 配置权限
    • 测试
  2. Cordova 适配

    • 实现 CordovaCameraService
    • 配置插件
    • 测试

第五阶段:测试和优化(2天)

  1. 全面测试

    • 单元测试
    • 集成测试
    • E2E 测试
  2. 性能优化

    • 图片压缩优化
    • 加载速度优化
    • 内存管理
  3. 用户体验优化

    • 错误提示优化
    • 交互流程优化
    • UI 细节调整

十、风险和对策

技术风险

风险 影响 对策
浏览器不支持摄像头 API 功能不可用 提供文件上传降级方案
权限被用户拒绝 无法拍照 清晰的权限说明和引导
网络不稳定导致上传失败 照片丢失 本地缓存 + 重试机制
不同环境 API 差异大 开发复杂 使用适配器模式统一接口
图片过大影响性能 上传慢、内存占用高 自动压缩和尺寸调整

业务风险

风险 影响 对策
患者隐私泄露 法律风险 加密传输、访问控制
照片与患者关联错误 医疗事故 严格的 UID 关联逻辑
拍照延迟影响急诊流程 效率降低 优化性能、提供跳过选项

十一、后续扩展

功能扩展

  1. 多张照片支持

    • 一次检查可以拍摄多张照片
    • 照片管理界面
  2. 照片编辑

    • 裁剪、旋转
    • 标注功能
  3. AI 辅助

    • 人脸识别
    • 自动对焦
  4. 历史照片查看

    • 在患者信息中查看历史照片
    • 照片对比功能

性能优化

  1. WebWorker 压缩

    • 在后台线程中压缩图片
    • 不阻塞主线程
  2. 增量上传

    • 大文件分片上传
    • 断点续传
  3. 缓存策略

    • 照片本地缓存
    • 离线上传队列

十二、总结

本文档详细描述了急诊拍照功能的完整实现方案,包括:

  1. 架构设计:采用适配器模式适配不同运行环境
  2. 数据流:清晰的状态管理和数据传递
  3. 用户体验:完整的交互流程和错误处理
  4. 技术实现:具体的代码结构和实现细节
  5. 质量保证:全面的测试策略和风险控制

通过本方案的实施,可以为急诊流程提供可靠的患者身份确认功能,避免患者信息混淆,提高医疗服务质量。