Browse Source

feat: 实现急诊拍照功能前端部分(浏览器环境测试通过)

功能概述:
- 实现急诊患者拍照功能,用于避免患者离开后Study对应错误
- 采用适配器模式设计,支持浏览器、Electron、Cordova三种环境
- 完整的拍照、预览、重拍流程

核心实现:
- Redux状态管理:cameraSlice管理摄像头状态和捕获图片
- 适配器模式:CameraService接口及三种环境实现
- CameraModal组件:完整的拍照界面和交互流程
- ContentAreaLarge集成:添加摄像头按钮和Modal渲染

关键修复:
- 修复video元素条件渲染导致videoRef为null的问题(使用遮罩层替代条件渲染)
- 修复onloadedmetadata事件绑定时机错误(先绑定事件再设置srcObject)
- 修复handleRetake重复初始化问题(移除手动调用,让useEffect自动处理)

技术文档:
- 新增docs/实现/急诊拍照功能.md,包含完整的架构设计、数据流、问题修复方案

当前状态:
 注意:目前仅实现前端部分,暂未实现后端API上传功能
 浏览器环境下拍照功能测试通过
 待实现:后端照片上传接口、Electron和Cordova环境测试

改动文件:
- docs/实现/急诊拍照功能.md (新增)
- src/components/CameraModal.tsx (新增)
- src/states/exam/cameraSlice.ts (新增)
- src/services/camera/CameraService.ts (新增)
- src/services/camera/BrowserCameraService.ts (新增)
- src/services/camera/ElectronCameraService.ts (新增)
- src/services/camera/CordovaCameraService.ts (新增)
- src/services/camera/CameraServiceFactory.ts (新增)
- src/pages/exam/ContentAreaLarge.tsx (修改)
- src/states/store.ts (修改)
- src/components/ExitModal.tsx (修改)
- types/global.d.ts (修改)
sw 1 month ago
parent
commit
81ecb06a99

+ 1459 - 0
docs/实现/急诊拍照功能.md

@@ -0,0 +1,1459 @@
+# 急诊拍照功能实现方案
+
+## 一、功能概述
+
+### 应用场景
+用户急诊进入检查,给患者拍照,避免出现患者离开后急诊得到的 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 结构:
+    ```typescript
+    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 实例
+  - 逻辑:
+    ```typescript
+    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)
+
+```typescript
+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. 患者照片领域模型
+
+```typescript
+/**
+ * 患者照片实体
+ */
+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 请求/响应类型
+
+```typescript
+/**
+ * 上传照片请求
+ */
+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 接口
+
+```typescript
+/**
+ * 摄像头服务接口
+ */
+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 类型
+
+```typescript
+/**
+ * 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 更新:
+     ```typescript
+     {
+       isOpen: true,
+       currentStudyInstanceUid: 'xxx',
+       error: null
+     }
+     ```
+
+3. **组件渲染**
+   - CameraModal 组件挂载
+   - useEffect 监听 `isOpen` 状态变化
+   - 开始摄像头初始化流程
+
+### 阶段 2:获取摄像头权限
+
+4. **环境检测**
+   - CameraServiceFactory 检测运行环境
+   - 创建对应的 CameraService 实例
+
+5. **权限请求**
+   - 调用 `cameraService.requestPermission()`
+   - 浏览器环境:
+     ```typescript
+     await navigator.mediaDevices.getUserMedia({ video: true })
+     ```
+   - 如果用户拒绝:
+     - dispatch `setError('用户拒绝了摄像头权限')`
+     - 显示错误提示
+     - 提供重试或关闭选项
+   - 如果用户同意:继续下一步
+
+### 阶段 3:启动摄像头
+
+6. **获取媒体流**
+   - 调用 `cameraService.getMediaStream()`
+   - 配置约束条件:
+     ```typescript
+     const constraints = {
+       video: {
+         width: { ideal: 1280 },
+         height: { ideal: 720 },
+         facingMode: 'user'
+       }
+     }
+     ```
+
+7. **设置视频源**
+   - 将 MediaStream 设置到 `<video>` 元素
+   - dispatch `setStream(stream)`
+   - video 元素开始播放
+
+8. **显示预览界面**
+   - CameraPreview 组件渲染
+   - 显示实时摄像头画面
+   - 显示拍照按钮
+
+### 阶段 4:实时预览
+
+9. **用户调整**
+   - 用户可以看到自己的实时画面
+   - 可以调整位置、角度
+   - 可以选择取消或拍照
+
+### 阶段 5:拍照
+
+10. **捕获画面**
+    - 用户点击"拍照"按钮
+    - 创建 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);
+      ```
+
+11. **转换为 base64**
+    - 使用 `canvas.toDataURL()` 转换
+    - 可选择压缩质量(如 0.8)
+    ```typescript
+    const base64 = canvas.toDataURL('image/jpeg', 0.8);
+    ```
+
+12. **可选:图片处理**
+    - 如果图片过大,进行压缩
+    - 如果尺寸不合适,进行缩放
+    - 调用 `imageUtils.compressImage()` 或 `resizeImage()`
+
+13. **更新状态**
+    - dispatch `setCapturedImage(base64)`
+    - 切换到 PhotoPreview 组件
+
+### 阶段 6:预览确认
+
+14. **显示预览**
+    - PhotoPreview 组件渲染
+    - 显示刚拍摄的照片
+    - 提供"重拍"和"发送"按钮
+
+15. **用户决策**
+    - **选择重拍**:
+      - dispatch `setCapturedImage(null)`
+      - 返回 CameraPreview 界面
+      - 继续显示实时画面
+    - **选择发送**:继续下一步
+
+### 阶段 7:上传照片
+
+16. **开始上传**
+    - dispatch `setLoading(true)`
+    - 显示加载指示器
+
+17. **调用 API**
+    - 调用 `uploadPatientPhoto(base64, studyInstanceUid)`
+    - 发送 HTTP POST 请求:
+      ```typescript
+      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
+        }
+      }
+      ```
+
+18. **后端处理**
+    - 后端接收照片
+    - 保存到存储系统
+    - 在数据库中创建关联记录
+    - 返回成功响应
+
+19. **处理响应**
+    - 如果成功:
+      - dispatch `setLoading(false)`
+      - 显示成功消息
+      - 继续清理流程
+    - 如果失败:
+      - dispatch `setError(errorMessage)`
+      - 显示错误信息
+      - 提供重试选项
+
+### 阶段 8:清理和关闭
+
+20. **停止媒体流**
+    - 调用 `cameraService.stopStream(stream)`
+    - 释放摄像头资源:
+      ```typescript
+      stream.getTracks().forEach(track => track.stop());
+      ```
+
+21. **重置状态**
+    - dispatch `resetState()`
+    - Redux state 恢复到初始值
+
+22. **关闭 Modal**
+    - dispatch `closeCamera()`
+    - CameraModal 组件卸载
+
+23. **显示结果**
+    - 在主界面显示成功提示
+    - 用户可以继续其他操作
+
+---
+
+## 七、端口适配器模式设计
+
+### 为什么需要适配器模式?
+
+应用需要运行在三个不同环境,每个环境访问摄像头的方式不同:
+
+| 环境 | 访问方式 | 特点 |
+|------|---------|------|
+| 浏览器 | `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. 浏览器实现
+
+```typescript
+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 实现
+
+```typescript
+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 实现
+
+```typescript
+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. 工厂类实现
+
+```typescript
+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 状态使用了条件渲染:
+
+```tsx
+{/* ❌ 错误的实现 */}
+{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 元素始终存在:
+
+```tsx
+{/* ✅ 正确的实现 */}
+{!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` 事件:
+
+```typescript
+// ❌ 错误的顺序
+videoRef.current.srcObject = stream;
+videoRef.current.onloadedmetadata = () => { ... };
+```
+
+如果流设置得太快,元数据加载可能在事件绑定前就完成了,导致事件永远不触发,视频无法播放。
+
+#### 解决方案
+
+必须先绑定事件,再设置 srcObject:
+
+```typescript
+// ✅ 正确的顺序
+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 中的初始化:
+
+```typescript
+// ❌ 导致重复初始化
+const handleRetake = () => {
+  dispatch(setCapturedImage(null));  // 触发 useEffect
+  initCamera();                      // 直接调用,导致重复
+};
+```
+
+#### 解决方案
+
+移除直接调用,让 useEffect 自动处理:
+
+```typescript
+// ✅ 避免重复初始化
+const handleRetake = () => {
+  dispatch(setCapturedImage(null));
+  // 不需要手动调用 initCamera(),useEffect 会自动处理
+};
+```
+
+useEffect 的依赖包含 `capturedImage`,当它变为 `null` 时会自动触发重新初始化。
+
+---
+
+## 九、技术要点和注意事项
+
+### 1. 安全性
+
+#### HTTPS 要求
+- 摄像头访问必须在 HTTPS 环境或 localhost 下
+- 生产环境必须配置 SSL 证书
+- 开发环境可以使用 localhost
+
+#### 权限管理
+- 必须获得用户明确授权
+- 权限被拒绝时提供友好的错误提示
+- 提供重新请求权限的入口
+
+#### 数据传输
+- 照片数据应该加密传输
+- 使用 HTTPS 协议
+- 考虑对敏感照片添加水印或加密
+
+### 2. 性能优化
+
+#### 图片压缩
+```typescript
+// 压缩策略
+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+
+
+#### 移动端适配
+- 响应式布局
+- 触摸友好的按钮尺寸
+- 横竖屏适配
+
+#### 降级方案
+```typescript
+// 检测浏览器支持
+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天)
+
+4. **实现 CameraModal**
+   - 基本结构和布局
+   - 生命周期管理
+   - 状态切换逻辑
+
+5. **实现 CameraPreview**
+   - 视频预览
+   - 拍照功能
+   - UI 交互
+
+6. **实现 PhotoPreview**
+   - 照片显示
+   - 重拍/发送功能
+   - 加载状态
+
+### 第三阶段:API 集成(1天)
+
+7. **实现 API 接口**
+   - 上传照片接口
+   - 错误处理
+   - 请求重试
+
+8. **集成到主流程**
+   - 修改 ContentAreaLarge
+   - 添加按钮事件
+   - 测试完整流程
+
+### 第四阶段:环境适配(2-3天)
+
+9. **Electron 适配**
+   - 实现 ElectronCameraService
+   - 配置权限
+   - 测试
+
+10. **Cordova 适配**
+    - 实现 CordovaCameraService
+    - 配置插件
+    - 测试
+
+### 第五阶段:测试和优化(2天)
+
+11. **全面测试**
+    - 单元测试
+    - 集成测试
+    - E2E 测试
+
+12. **性能优化**
+    - 图片压缩优化
+    - 加载速度优化
+    - 内存管理
+
+13. **用户体验优化**
+    - 错误提示优化
+    - 交互流程优化
+    - UI 细节调整
+
+---
+
+## 十、风险和对策
+
+### 技术风险
+
+| 风险 | 影响 | 对策 |
+|------|------|------|
+| 浏览器不支持摄像头 API | 功能不可用 | 提供文件上传降级方案 |
+| 权限被用户拒绝 | 无法拍照 | 清晰的权限说明和引导 |
+| 网络不稳定导致上传失败 | 照片丢失 | 本地缓存 + 重试机制 |
+| 不同环境 API 差异大 | 开发复杂 | 使用适配器模式统一接口 |
+| 图片过大影响性能 | 上传慢、内存占用高 | 自动压缩和尺寸调整 |
+
+### 业务风险
+
+| 风险 | 影响 | 对策 |
+|------|------|------|
+| 患者隐私泄露 | 法律风险 | 加密传输、访问控制 |
+| 照片与患者关联错误 | 医疗事故 | 严格的 UID 关联逻辑 |
+| 拍照延迟影响急诊流程 | 效率降低 | 优化性能、提供跳过选项 |
+
+---
+
+## 十一、后续扩展
+
+### 功能扩展
+
+1. **多张照片支持**
+   - 一次检查可以拍摄多张照片
+   - 照片管理界面
+
+2. **照片编辑**
+   - 裁剪、旋转
+   - 标注功能
+
+3. **AI 辅助**
+   - 人脸识别
+   - 自动对焦
+
+4. **历史照片查看**
+   - 在患者信息中查看历史照片
+   - 照片对比功能
+
+### 性能优化
+
+1. **WebWorker 压缩**
+   - 在后台线程中压缩图片
+   - 不阻塞主线程
+
+2. **增量上传**
+   - 大文件分片上传
+   - 断点续传
+
+3. **缓存策略**
+   - 照片本地缓存
+   - 离线上传队列
+
+---
+
+## 十二、总结
+
+本文档详细描述了急诊拍照功能的完整实现方案,包括:
+
+1. **架构设计**:采用适配器模式适配不同运行环境
+2. **数据流**:清晰的状态管理和数据传递
+3. **用户体验**:完整的交互流程和错误处理
+4. **技术实现**:具体的代码结构和实现细节
+5. **质量保证**:全面的测试策略和风险控制
+
+通过本方案的实施,可以为急诊流程提供可靠的患者身份确认功能,避免患者信息混淆,提高医疗服务质量。

+ 369 - 0
src/components/CameraModal.tsx

@@ -0,0 +1,369 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Modal, Button, Space, message, Spin } from 'antd';
+import { CameraOutlined, CloseOutlined, SendOutlined, ReloadOutlined } from '@ant-design/icons';
+import { useDispatch, useSelector } from 'react-redux';
+import { RootState } from '@/states/store';
+import { closeCamera, setCapturedImage, setError, setLoading } from '@/states/exam/cameraSlice';
+import { CameraServiceFactory } from '@/services/camera/CameraServiceFactory';
+
+interface CameraModalProps {
+  visible: boolean;
+  studyInstanceUid: string;
+  onClose: () => void;
+}
+
+/**
+ * 摄像头 Modal 组件
+ * 用于急诊拍照功能
+ */
+const CameraModal: React.FC<CameraModalProps> = ({
+  visible,
+  studyInstanceUid,
+  onClose,
+}) => {
+  const dispatch = useDispatch();
+  const videoRef = useRef<HTMLVideoElement>(null);
+  const streamRef = useRef<MediaStream | null>(null);
+  
+  const capturedImage = useSelector((state: RootState) => state.camera.capturedImage);
+  const isLoading = useSelector((state: RootState) => state.camera.isLoading);
+  const error = useSelector((state: RootState) => state.camera.error);
+
+  const [isInitializing, setIsInitializing] = useState(false);
+
+  /**
+   * 初始化摄像头
+   * 使用重试机制确保 video 元素已完全挂载
+   */
+  useEffect(() => {
+    let retryCount = 0;
+    const MAX_RETRIES = 3;
+    const RETRY_DELAY = 1000; // 1秒
+    let retryTimer: NodeJS.Timeout | null = null;
+
+    const tryInitCamera = () => {
+      if (videoRef.current) {
+        // video 元素已就绪,立即初始化
+        console.log('[CameraModal] video 元素已就绪,开始初始化摄像头');
+        initCamera();
+      } else if (retryCount < MAX_RETRIES) {
+        // video 元素未就绪,重试
+        retryCount++;
+        console.log(
+          `[CameraModal] video 元素未就绪,${RETRY_DELAY}ms 后进行第 ${retryCount} 次重试(共 ${MAX_RETRIES} 次)`
+        );
+
+        retryTimer = setTimeout(() => {
+          tryInitCamera();
+        }, RETRY_DELAY);
+      } else {
+        // 重试次数用完,显示错误
+        console.error('[CameraModal] video 元素初始化失败,已达最大重试次数');
+        dispatch(setError('视频元素初始化失败,请关闭后重新打开'));
+        message.error('视频元素初始化失败,请重试');
+        setIsInitializing(false);
+      }
+    };
+
+    if (visible && !capturedImage) {
+      tryInitCamera();
+    }
+
+    // 清理函数
+    return () => {
+      if (retryTimer) {
+        clearTimeout(retryTimer);
+      }
+      stopCamera();
+    };
+  }, [visible, capturedImage]);
+
+  /**
+   * 初始化摄像头
+   */
+  const initCamera = async () => {
+    setIsInitializing(true);
+    
+    try {
+      console.log('[CameraModal] 初始化摄像头...');
+      
+      // 创建摄像头服务
+      const cameraService = CameraServiceFactory.create();
+      
+      // 请求权限
+      const hasPermission = await cameraService.requestPermission();
+      if (!hasPermission) {
+        throw new Error('用户拒绝了摄像头权限');
+      }
+
+      // 获取媒体流
+      const stream = await cameraService.getMediaStream({
+        video: {
+          width: { ideal: 1280 },
+          height: { ideal: 720 },
+          facingMode: 'user',
+        },
+      });
+
+      // 保存流的引用
+      streamRef.current = stream;
+
+      // 设置到 video 元素
+      console.warn(`为videoRef.current 绑定事件,看看它存在不 ${videoRef.current}`)
+      if (videoRef.current) {
+        console.log(`绑定加载事件 onloadedmetadata`)
+        // 重要:先绑定事件,再设置 srcObject
+        videoRef.current.onloadedmetadata = async () => {
+          try {
+            if (videoRef.current) {
+              await videoRef.current.play();
+              console.log('[CameraModal] 视频开始播放');
+              console.log('[CameraModal] 视频尺寸:', 
+                videoRef.current.videoWidth, 'x', videoRef.current.videoHeight);
+            }
+          } catch (playError: any) {
+            console.error('[CameraModal] 视频播放失败:', playError);
+            message.error('视频播放失败: ' + playError.message);
+          }
+        };
+        
+        // 先绑定事件后再设置流
+        videoRef.current.srcObject = stream;
+      }
+
+      console.log('[CameraModal] 摄像头初始化成功');
+    } catch (err: any) {
+      console.error('[CameraModal] 摄像头初始化失败:', err);
+      dispatch(setError(err.message || '摄像头初始化失败'));
+      message.error(err.message || '摄像头初始化失败');
+    } finally {
+      setIsInitializing(false);
+    }
+  };
+
+  /**
+   * 停止摄像头
+   */
+  const stopCamera = () => {
+    if (streamRef.current) {
+      console.log('[CameraModal] 停止摄像头');
+      streamRef.current.getTracks().forEach((track) => track.stop());
+      streamRef.current = null;
+    }
+  };
+
+  /**
+   * 拍照
+   */
+  const handleCapture = async () => {
+    if (!streamRef.current) {
+      message.error('摄像头未就绪');
+      return;
+    }
+
+    try {
+      console.log('[CameraModal] 开始拍照...');
+      
+      const cameraService = CameraServiceFactory.create();
+      const base64Image = await cameraService.capturePhoto(streamRef.current);
+      
+      console.log('[CameraModal] 拍照成功,图片大小:', base64Image.length);
+      
+      // 保存图片到 Redux
+      dispatch(setCapturedImage(base64Image));
+      
+      // 停止摄像头
+      stopCamera();
+      
+      message.success('拍照成功');
+    } catch (err: any) {
+      console.error('[CameraModal] 拍照失败:', err);
+      message.error(err.message || '拍照失败');
+    }
+  };
+
+  /**
+   * 重拍
+   */
+  const handleRetake = () => {
+    console.log('[CameraModal] 重拍');
+    dispatch(setCapturedImage(null));
+    // 不需要手动调用 initCamera(),useEffect 会自动处理
+  };
+
+  /**
+   * 发送照片
+   */
+  const handleSend = async () => {
+    if (!capturedImage) {
+      message.error('没有可发送的照片');
+      return;
+    }
+
+    try {
+      dispatch(setLoading(true));
+      
+      console.log('[CameraModal] 发送照片到服务器...');
+      console.log('[CameraModal] Study UID:', studyInstanceUid);
+      
+      // TODO: 调用 API 上传照片
+      // await uploadPatientPhoto(capturedImage, studyInstanceUid);
+      
+      // 模拟上传
+      await new Promise((resolve) => setTimeout(resolve, 1000));
+      
+      message.success('照片发送成功');
+      
+      // 关闭 Modal
+      handleClose();
+    } catch (err: any) {
+      console.error('[CameraModal] 发送照片失败:', err);
+      message.error(err.message || '发送照片失败');
+    } finally {
+      dispatch(setLoading(false));
+    }
+  };
+
+  /**
+   * 关闭 Modal
+   */
+  const handleClose = () => {
+    console.log('[CameraModal] 关闭 Modal');
+    stopCamera();
+    dispatch(setCapturedImage(null));
+    dispatch(setError(null));
+    onClose();
+  };
+
+  return (
+    <Modal
+      title="患者拍照"
+      open={visible}
+      onCancel={handleClose}
+      footer={null}
+      width={800}
+      centered
+      destroyOnClose
+    >
+      <div style={{ textAlign: 'center' }}>
+        {/* 错误提示 */}
+        {error && (
+          <div style={{ color: 'red', marginBottom: 16 }}>
+            {error}
+          </div>
+        )}
+
+        {/* 摄像头预览区域 - video 元素始终存在 */}
+        {!capturedImage && (
+          <div style={{ position: 'relative' }}>
+            {/* video 元素始终渲染,不因 isInitializing 而卸载 */}
+            <video
+              ref={videoRef}
+              autoPlay
+              playsInline
+              muted
+              style={{
+                width: '100%',
+                maxHeight: '500px',
+                backgroundColor: '#000',
+                borderRadius: 8,
+                display: isInitializing ? 'none' : 'block',
+              }}
+            />
+            
+            {/* 加载遮罩层 */}
+            {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
+                  type="primary"
+                  size="large"
+                  icon={<CameraOutlined />}
+                  onClick={handleCapture}
+                >
+                  拍照
+                </Button>
+                
+                <Button
+                  size="large"
+                  icon={<CloseOutlined />}
+                  onClick={handleClose}
+                >
+                  取消
+                </Button>
+              </Space>
+            )}
+          </div>
+        )}
+
+        {/* 照片预览 */}
+        {capturedImage && !isInitializing && (
+          <div>
+            <img
+              src={capturedImage}
+              alt="拍摄的照片"
+              style={{
+                width: '100%',
+                maxHeight: '500px',
+                borderRadius: 8,
+                objectFit: 'contain',
+              }}
+            />
+            
+            <Space style={{ marginTop: 24 }} size="large">
+              <Button
+                type="primary"
+                size="large"
+                icon={<SendOutlined />}
+                onClick={handleSend}
+                loading={isLoading}
+              >
+                发送
+              </Button>
+              
+              <Button
+                size="large"
+                icon={<ReloadOutlined />}
+                onClick={handleRetake}
+                disabled={isLoading}
+              >
+                重拍
+              </Button>
+              
+              <Button
+                size="large"
+                icon={<CloseOutlined />}
+                onClick={handleClose}
+                disabled={isLoading}
+              >
+                取消
+              </Button>
+            </Space>
+          </div>
+        )}
+      </div>
+    </Modal>
+  );
+};
+
+export default CameraModal;

+ 2 - 0
src/components/ExitModal.tsx

@@ -24,6 +24,8 @@ declare global {
         error?: string;
         requiresAdmin?: boolean;
       }>;
+      checkCameraPermission?: () => Promise<boolean>;
+      requestCameraPermission?: () => Promise<boolean>;
     };
   }
 }

+ 36 - 0
src/pages/exam/ContentAreaLarge.tsx

@@ -36,6 +36,8 @@ import { useRef } from 'react';
 import Icon from '@/components/Icon';
 import ParaSettingCoordinator from '@/domain/exam/paraSettingCoordinator';
 import { resetDevices } from '@/states/device/deviceSlice';
+import { openCamera, closeCamera } from '@/states/exam/cameraSlice';
+import CameraModal from '@/components/CameraModal';
 
 const ContentAreaLarge = () => {
   const dispatch = useDispatch<AppDispatch>();
@@ -91,6 +93,24 @@ const ContentAreaLarge = () => {
     }
   };
 
+  /**
+   * 处理打开摄像头按钮点击事件
+   */
+  const handleOpenCamera = () => {
+    // 获取当前选中体位的 study_instance_uid
+    const currentStudyInstanceUid = selectedBodyPosition?.study_instance_uid;
+
+    if (!currentStudyInstanceUid) {
+      message.warning('请先选择一个体位');
+      return;
+    }
+
+    console.log('[handleOpenCamera] 打开摄像头,Study UID:', currentStudyInstanceUid);
+    
+    // dispatch openCamera action,打开摄像头
+    dispatch(openCamera(currentStudyInstanceUid));
+  };
+
   // 1. 正常在顶层用 useSelector 订阅
   const selectedBodyPosition = useSelector(
     (state: RootState) => state.bodyPositionList.selectedBodyPosition
@@ -100,6 +120,12 @@ const ContentAreaLarge = () => {
   const positionRef = useRef(selectedBodyPosition);
   positionRef.current = selectedBodyPosition;
 
+  // 3. 订阅 camera 状态
+  const cameraIsOpen = useSelector((state: RootState) => state.camera.isOpen);
+  const cameraStudyInstanceUid = useSelector(
+    (state: RootState) => state.camera.currentStudyInstanceUid
+  );
+
   return (
     <Row className="w-full p-1" style={{ height: '100%', display: 'flex' }}>
       <Col span={20} style={{ display: 'flex', flexDirection: 'column' }}>
@@ -478,6 +504,7 @@ const ContentAreaLarge = () => {
                   state="normal"
                 />
               }
+              onClick={handleOpenCamera}
             />
           </Tooltip>
 
@@ -499,6 +526,15 @@ const ContentAreaLarge = () => {
         </Flex>
         <ErrorMessage />
       </Col>
+
+      {/* 摄像头 Modal */}
+      {cameraIsOpen && cameraStudyInstanceUid && (
+        <CameraModal
+          visible={cameraIsOpen}
+          studyInstanceUid={cameraStudyInstanceUid}
+          onClose={() => dispatch(closeCamera())}
+        />
+      )}
     </Row>
   );
 };

+ 146 - 0
src/services/camera/BrowserCameraService.ts

@@ -0,0 +1,146 @@
+import {
+    ICameraService,
+    MediaStreamConstraints,
+} from './CameraService';
+
+/**
+ * 浏览器环境下的摄像头服务实现
+ * 使用标准的 Web API
+ */
+export class BrowserCameraService implements ICameraService {
+    /**
+     * 请求摄像头权限
+     * @returns 是否成功获取权限
+     */
+    async requestPermission(): Promise<boolean> {
+        try {
+            // 检查是否在安全上下文中
+            if (!window.isSecureContext) {
+                throw new Error(
+                    '摄像头访问需要安全上下文(HTTPS)。' +
+                    '请使用 HTTPS 访问页面,或在 localhost 环境下测试。' +
+                    `当前访问地址:${window.location.href}`
+                );
+            }
+
+            // 检查浏览器 API 支持
+            if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+                throw new Error('浏览器不支持摄像头 API,请使用最新版浏览器');
+            }
+
+            // 检查是否有摄像头设备
+            const devices = await navigator.mediaDevices.enumerateDevices();
+            const hasCamera = devices.some(device => device.kind === 'videoinput');
+
+            if (!hasCamera) {
+                throw new Error('未检测到摄像头设备,请连接摄像头后重试');
+            }
+
+            // 请求权限
+            const stream = await navigator.mediaDevices.getUserMedia({ video: true });
+            stream.getTracks().forEach(track => track.stop());
+
+            return true;
+        } catch (error: any) {
+            console.error('摄像头权限请求失败:', error);
+            throw error;
+        }
+    }
+
+
+    /**
+     * 获取媒体流
+     * @param constraints 媒体约束条件
+     * @returns MediaStream 对象
+     */
+    async getMediaStream(
+        constraints: MediaStreamConstraints = { video: true }
+    ): Promise<MediaStream> {
+        try {
+            // 检查浏览器支持
+            if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+                throw new Error('当前浏览器不支持摄像头访问');
+            }
+
+            // 获取媒体流
+            const stream = await navigator.mediaDevices.getUserMedia(
+                constraints as globalThis.MediaStreamConstraints
+            );
+
+            return stream;
+        } catch (error: any) {
+            // 处理不同的错误类型
+            if (error.name === 'NotAllowedError') {
+                throw new Error('用户拒绝了摄像头权限');
+            } else if (error.name === 'NotFoundError') {
+                throw new Error('未找到摄像头设备');
+            } else if (error.name === 'NotReadableError') {
+                throw new Error('摄像头正被其他应用使用');
+            } else {
+                throw new Error(`获取摄像头失败: ${error.message}`);
+            }
+        }
+    }
+
+    /**
+     * 从媒体流中捕获照片
+     * @param stream 媒体流对象
+     * @returns base64 格式的图片数据
+     */
+    async capturePhoto(stream: MediaStream): Promise<string> {
+        return new Promise((resolve, reject) => {
+            try {
+                // 创建 video 元素
+                const video = document.createElement('video');
+                video.srcObject = stream;
+                video.autoplay = true;
+                video.playsInline = true; // iOS 需要
+
+                video.onloadedmetadata = () => {
+                    // 等待视频准备好
+                    video.play();
+
+                    // 创建 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);
+
+                    // 清理
+                    video.srcObject = null;
+
+                    resolve(base64);
+                };
+
+                video.onerror = () => {
+                    reject(new Error('视频加载失败'));
+                };
+            } catch (error) {
+                reject(error);
+            }
+        });
+    }
+
+    /**
+     * 停止媒体流
+     * @param stream 要停止的媒体流
+     */
+    stopStream(stream: MediaStream): void {
+        if (stream) {
+            stream.getTracks().forEach((track) => {
+                track.stop();
+            });
+        }
+    }
+}

+ 45 - 0
src/services/camera/CameraService.ts

@@ -0,0 +1,45 @@
+/**
+ * 媒体流约束条件
+ */
+export interface MediaStreamConstraints {
+  video?:
+    | boolean
+    | {
+        width?: { ideal: number };
+        height?: { ideal: number };
+        facingMode?: 'user' | 'environment';
+      };
+  audio?: boolean;
+}
+
+/**
+ * 摄像头服务接口
+ * 定义统一的摄像头操作接口,适配不同运行环境
+ */
+export 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;
+}

+ 63 - 0
src/services/camera/CameraServiceFactory.ts

@@ -0,0 +1,63 @@
+import { ICameraService } from './CameraService';
+import { BrowserCameraService } from './BrowserCameraService';
+import { ElectronCameraService } from './ElectronCameraService';
+import { CordovaCameraService } from './CordovaCameraService';
+
+/**
+ * 摄像头服务工厂
+ * 根据运行环境创建对应的 CameraService 实例
+ */
+export class CameraServiceFactory {
+  private static instance: ICameraService | null = null;
+
+  /**
+   * 创建摄像头服务实例
+   * @returns ICameraService 实例
+   */
+  static create(): ICameraService {
+    // 如果已经创建过实例,直接返回
+    if (this.instance) {
+      return this.instance;
+    }
+
+    // 检测 Cordova 环境
+    if (window.cordova) {
+      console.log('[CameraServiceFactory] 检测到 Cordova 环境,使用 CordovaCameraService');
+      this.instance = new CordovaCameraService();
+    }
+    // 检测 Electron 环境
+    else if (window.electronAPI) {
+      console.log('[CameraServiceFactory] 检测到 Electron 环境,使用 ElectronCameraService');
+      this.instance = new ElectronCameraService();
+    }
+    // 浏览器环境
+    else {
+      console.log('[CameraServiceFactory] 检测到浏览器环境,使用 BrowserCameraService');
+      this.instance = new BrowserCameraService();
+    }
+
+    return this.instance;
+  }
+
+  /**
+   * 重置实例
+   * 用于测试或需要重新创建实例的场景
+   */
+  static reset(): void {
+    this.instance = null;
+  }
+
+  /**
+   * 检测当前运行环境
+   * @returns 环境类型:'cordova' | 'electron' | 'browser'
+   */
+  static detectEnvironment(): 'cordova' | 'electron' | 'browser' {
+    if (window.cordova) {
+      return 'cordova';
+    } else if (window.electronAPI) {
+      return 'electron';
+    } else {
+      return 'browser';
+    }
+  }
+}

+ 92 - 0
src/services/camera/CordovaCameraService.ts

@@ -0,0 +1,92 @@
+import {
+  ICameraService,
+  MediaStreamConstraints,
+} from './CameraService';
+
+/**
+ * Cordova 环境下的摄像头服务实现
+ * 使用 Cordova Camera 插件
+ */
+export class CordovaCameraService implements ICameraService {
+  /**
+   * 请求摄像头权限
+   * Cordova 插件通常在第一次使用时自动请求权限
+   * @returns 总是返回 true
+   */
+  async requestPermission(): Promise<boolean> {
+    // Cordova 插件会在实际使用时请求权限
+    return true;
+  }
+
+  /**
+   * 获取媒体流
+   * Cordova 不使用 MediaStream,此方法会抛出错误
+   * @param constraints 媒体约束条件(未使用)
+   * @returns 抛出错误
+   */
+  async getMediaStream(
+    constraints?: MediaStreamConstraints
+  ): Promise<MediaStream> {
+    throw new Error(
+      'Cordova 环境不支持 MediaStream,请直接使用 capturePhoto 方法'
+    );
+  }
+
+  /**
+   * 捕获照片
+   * 使用 Cordova Camera 插件直接拍照
+   * @param stream 媒体流(在 Cordova 中未使用)
+   * @returns base64 格式的图片数据
+   */
+  async capturePhoto(stream?: MediaStream): Promise<string> {
+    return new Promise((resolve, reject) => {
+      // 检查 Cordova Camera 插件是否可用
+      if (!navigator.camera) {
+        reject(new Error('Cordova Camera 插件未安装或不可用'));
+        return;
+      }
+
+      // 检查 Camera 常量是否可用
+      if (typeof Camera === 'undefined') {
+        reject(new Error('Camera 常量不可用,请检查 Cordova 插件配置'));
+        return;
+      }
+
+      // 配置 Camera 选项
+      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,
+        saveToPhotoAlbum: false,
+      };
+
+      // 调用 Camera 插件
+      navigator.camera.getPicture(
+        (imageData) => {
+          // 添加 base64 前缀
+          const base64WithPrefix = `data:image/jpeg;base64,${imageData}`;
+          resolve(base64WithPrefix);
+        },
+        (error) => {
+          reject(new Error(`拍照失败: ${error}`));
+        },
+        options
+      );
+    });
+  }
+
+  /**
+   * 停止媒体流
+   * Cordova 不需要停止流
+   * @param stream 媒体流(未使用)
+   */
+  stopStream(stream: MediaStream): void {
+    // Cordova 不需要停止流
+    // 什么也不做
+  }
+}

+ 78 - 0
src/services/camera/ElectronCameraService.ts

@@ -0,0 +1,78 @@
+import {
+  ICameraService,
+  MediaStreamConstraints,
+} from './CameraService';
+import { BrowserCameraService } from './BrowserCameraService';
+
+/**
+ * Electron 环境下的摄像头服务实现
+ * 主要使用标准 Web API,但可能需要特殊的权限配置
+ */
+export class ElectronCameraService implements ICameraService {
+  private browserService: BrowserCameraService;
+
+  constructor() {
+    this.browserService = new BrowserCameraService();
+  }
+
+  /**
+   * 请求摄像头权限
+   * Electron 环境可能需要在主进程中配置权限
+   * @returns 是否成功获取权限
+   */
+  async requestPermission(): Promise<boolean> {
+    try {
+      // 检查是否有 electronAPI
+      if (window.electronAPI && window.electronAPI.checkCameraPermission) {
+        const hasPermission = await window.electronAPI.checkCameraPermission();
+        if (!hasPermission && window.electronAPI.requestCameraPermission) {
+          const granted = await window.electronAPI.requestCameraPermission();
+          return granted;
+        }
+        return hasPermission;
+      }
+
+      // 如果没有特殊的 API,使用标准方式
+      return await this.browserService.requestPermission();
+    } catch (error) {
+      console.error('Electron 摄像头权限请求失败:', error);
+      // 降级到标准浏览器方式
+      return await this.browserService.requestPermission();
+    }
+  }
+
+  /**
+   * 获取媒体流
+   * Electron 可以使用标准的 getUserMedia
+   * @param constraints 媒体约束条件
+   * @returns MediaStream 对象
+   */
+  async getMediaStream(
+    constraints: MediaStreamConstraints = { video: true }
+  ): Promise<MediaStream> {
+    try {
+      // Electron 环境下可以直接使用标准 API
+      return await this.browserService.getMediaStream(constraints);
+    } catch (error: any) {
+      throw new Error(`Electron 获取摄像头失败: ${error.message}`);
+    }
+  }
+
+  /**
+   * 从媒体流中捕获照片
+   * 与浏览器实现相同
+   * @param stream 媒体流对象
+   * @returns base64 格式的图片数据
+   */
+  async capturePhoto(stream: MediaStream): Promise<string> {
+    return await this.browserService.capturePhoto(stream);
+  }
+
+  /**
+   * 停止媒体流
+   * @param stream 要停止的媒体流
+   */
+  stopStream(stream: MediaStream): void {
+    this.browserService.stopStream(stream);
+  }
+}

+ 130 - 0
src/states/exam/cameraSlice.ts

@@ -0,0 +1,130 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+/**
+ * 摄像头状态接口
+ */
+export interface CameraState {
+  /** 摄像头是否开启 */
+  isOpen: boolean;
+  /** 媒体流对象(注意:不能直接存储在 Redux 中,仅用于类型定义) */
+  stream: MediaStream | null;
+  /** 捕获的图片(base64 格式) */
+  capturedImage: string | null;
+  /** 加载状态 */
+  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,
+};
+
+/**
+ * 摄像头 Slice
+ */
+const cameraSlice = createSlice({
+  name: 'camera',
+  initialState,
+  reducers: {
+    /**
+     * 打开摄像头
+     * @param state 当前状态
+     * @param action 包含 studyInstanceUid 的 action
+     */
+    openCamera: (state, action: PayloadAction<string>) => {
+      state.isOpen = true;
+      state.currentStudyInstanceUid = action.payload;
+      state.error = null;
+    },
+
+    /**
+     * 关闭摄像头
+     * @param state 当前状态
+     */
+    closeCamera: (state) => {
+      state.isOpen = false;
+      state.stream = null;
+      state.capturedImage = null;
+      state.error = null;
+    },
+
+    /**
+     * 设置媒体流
+     * 注意:MediaStream 对象不应该直接存储在 Redux 中
+     * 这里仅作为标记,实际的 stream 应该在组件中管理
+     * @param state 当前状态
+     * @param action 包含 stream 的 action
+     */
+    setStream: (state, action: PayloadAction<MediaStream | null>) => {
+      // 在实际使用中,我们只存储一个标记,而不是实际的 MediaStream
+      // MediaStream 应该在组件的 ref 中管理
+      state.stream = action.payload;
+    },
+
+    /**
+     * 设置捕获的图片
+     * @param state 当前状态
+     * @param action 包含 base64 图片数据的 action
+     */
+    setCapturedImage: (state, action: PayloadAction<string | null>) => {
+      state.capturedImage = action.payload;
+    },
+
+    /**
+     * 设置加载状态
+     * @param state 当前状态
+     * @param action 包含加载状态的 action
+     */
+    setLoading: (state, action: PayloadAction<boolean>) => {
+      state.isLoading = action.payload;
+    },
+
+    /**
+     * 设置错误信息
+     * @param state 当前状态
+     * @param action 包含错误信息的 action
+     */
+    setError: (state, action: PayloadAction<string | null>) => {
+      state.error = action.payload;
+      state.isLoading = false;
+    },
+
+    /**
+     * 重置状态
+     * @param state 当前状态
+     */
+    resetState: (state) => {
+      state.isOpen = false;
+      state.stream = null;
+      state.capturedImage = null;
+      state.isLoading = false;
+      state.error = null;
+      state.currentStudyInstanceUid = null;
+    },
+  },
+});
+
+// 导出 actions
+export const {
+  openCamera,
+  closeCamera,
+  setStream,
+  setCapturedImage,
+  setLoading,
+  setError,
+  resetState,
+} = cameraSlice.actions;
+
+// 导出 reducer
+export default cameraSlice.reducer;

+ 2 - 0
src/states/store.ts

@@ -61,6 +61,7 @@ import permissionReducer from './permissionSlice';
 import i18nReducer from './i18nSlice';
 import featureNotAvailableReducer from './featureNotAvailableSlice';
 import themeReducer from './themeSlice';
+import cameraReducer from './exam/cameraSlice';
 
 const store = configureStore({
   reducer: {
@@ -116,6 +117,7 @@ const store = configureStore({
     i18n: i18nReducer,
     featureNotAvailable: featureNotAvailableReducer,
     theme: themeReducer,
+    camera: cameraReducer,
   },
   middleware: (getDefaultMiddleware) =>
     getDefaultMiddleware().concat(

+ 42 - 0
types/global.d.ts

@@ -36,3 +36,45 @@ declare namespace NodeJS {
     USE_MSW?: string;
   }
 }
+
+// Cordova API 类型定义
+declare global {
+  interface Window {
+    cordova?: any;
+  }
+
+  interface Navigator {
+    camera?: {
+      getPicture: (
+        successCallback: (imageData: string) => void,
+        errorCallback: (error: string) => void,
+        options: any
+      ) => void;
+    };
+  }
+
+  // Cordova Camera 插件类型定义
+  const Camera: {
+    DestinationType: {
+      DATA_URL: number;
+      FILE_URI: number;
+      NATIVE_URI: number;
+    };
+    PictureSourceType: {
+      PHOTOLIBRARY: number;
+      CAMERA: number;
+      SAVEDPHOTOALBUM: number;
+    };
+    EncodingType: {
+      JPEG: number;
+      PNG: number;
+    };
+    MediaType: {
+      PICTURE: number;
+      VIDEO: number;
+      ALLMEDIA: number;
+    };
+  };
+}
+
+export {};