|
|
@@ -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. **质量保证**:全面的测试策略和风险控制
|
|
|
+
|
|
|
+通过本方案的实施,可以为急诊流程提供可靠的患者身份确认功能,避免患者信息混淆,提高医疗服务质量。
|