用户急诊进入检查,给患者拍照,避免出现患者离开后急诊得到的 study 对应不上哪个患者。
用户点击按钮 → 打开摄像头 → 拍照 → 预览确认 → 发送到服务器
ContentAreaLarge.tsx
CameraModal.tsx (新建)
CameraPreview.tsx (新建)
PhotoPreview.tsx (新建)
cameraSlice.ts (新建)
src/states/exam/cameraSlice.ts
State 结构:
interface CameraState {
isOpen: boolean; // 摄像头是否开启
stream: MediaStream | null; // 媒体流对象
capturedImage: string | null; // 捕获的图片(base64)
isLoading: boolean; // 加载状态
error: string | null; // 错误信息
currentStudyInstanceUid: string | null; // 当前 Study UID
}
openCamera()
- 打开摄像头closeCamera()
- 关闭摄像头setStream()
- 设置媒体流setCapturedImage()
- 设置捕获的图片setLoading()
- 设置加载状态setError()
- 设置错误信息resetState()
- 重置状态src/API/patient/cameraActions.ts
uploadPatientPhoto(photo: string, studyInstanceUid: string): Promise<UploadPhotoResponse>
getPatientPhoto(studyInstanceUid: string): Promise<string>
deletePatientPhoto(photoId: string): Promise<void>
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
CordovaCameraService.ts (新建)
src/services/camera/CordovaCameraService.ts
CameraServiceFactory.ts (新建)
src/services/camera/CameraServiceFactory.ts
逻辑:
class CameraServiceFactory {
static create(): ICameraService {
if (window.cordova) {
return new CordovaCameraService();
} else if (window.electronAPI) {
return new ElectronCameraService();
} else {
return new BrowserCameraService();
}
}
}
src/domain/patientPhoto.ts
interface PhotoMetadata { width: number; height: number; format: string; size: number; deviceInfo?: string; } ```
src/utils/imageUtils.ts
base64ToBlob(base64: string): Blob
- base64 转换为 BlobcompressImage(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,重置状态
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,
};
/**
* 患者照片实体
*/
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;
}
/**
* 上传照片请求
*/
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;
};
}
/**
* 摄像头服务接口
*/
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;
}
/**
* 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;
}
用户操作
handleOpenCamera
事件处理函数状态初始化
studyInstanceUid
openCamera(studyInstanceUid)
actionRedux state 更新:
{
isOpen: true,
currentStudyInstanceUid: 'xxx',
error: null
}
isOpen
状态变化创建对应的 CameraService 实例
权限请求
调用 cameraService.requestPermission()
浏览器环境:
await navigator.mediaDevices.getUserMedia({ video: true })
如果用户拒绝:
setError('用户拒绝了摄像头权限')
如果用户同意:继续下一步
获取媒体流
cameraService.getMediaStream()
配置约束条件:
const constraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user'
}
}
<video>
元素setStream(stream)
video 元素开始播放
显示预览界面
CameraPreview 组件渲染
显示实时摄像头画面
显示拍照按钮
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);
转换为 base64
canvas.toDataURL()
转换可选择压缩质量(如 0.8)
const base64 = canvas.toDataURL('image/jpeg', 0.8);
调用 imageUtils.compressImage()
或 resizeImage()
更新状态
dispatch setCapturedImage(base64)
切换到 PhotoPreview 组件
提供"重拍"和"发送"按钮
用户决策
选择重拍:
dispatch setCapturedImage(null)
返回 CameraPreview 界面
继续显示实时画面
选择发送:继续下一步
setLoading(true)
显示加载指示器
调用 API
调用 uploadPatientPhoto(base64, studyInstanceUid)
发送 HTTP POST 请求:
POST /api/patient/photo
{
study_instance_uid: 'xxx',
photo_data: 'data:image/jpeg;base64,xxx',
timestamp: '2025-01-11T10:00:00Z',
metadata: {
width: 1280,
height: 720,
format: 'image/jpeg',
size: 123456
}
}
后端处理
处理响应
setLoading(false)
setError(errorMessage)
停止媒体流
cameraService.stopStream(stream)
释放摄像头资源:
stream.getTracks().forEach(track => track.stop());
resetState()
Redux state 恢复到初始值
关闭 Modal
dispatch closeCamera()
CameraModal 组件卸载
显示结果
在主界面显示成功提示
用户可以继续其他操作
应用需要运行在三个不同环境,每个环境访问摄像头的方式不同:
环境 | 访问方式 | 特点 |
---|---|---|
浏览器 | 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;
}
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());
}
}
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());
}
}
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 不需要停止流
}
}
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;
}
}
在初始实现中,video 元素和 loading 状态使用了条件渲染:
{/* ❌ 错误的实现 */}
{isInitializing && <Spin tip="正在初始化摄像头..." />}
{!isInitializing && <video ref={videoRef} ... />}
问题流程:
setIsInitializing(true)
!isInitializing
变为 false
if (videoRef.current)
失败,无法绑定事件使用遮罩层而不是条件渲染,确保 video 元素始终存在:
{/* ✅ 正确的实现 */}
{!capturedImage && (
<div style={{ position: 'relative' }}>
{/* video 元素始终存在,只是通过 display 控制可见性 */}
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
maxHeight: '500px',
backgroundColor: '#000',
borderRadius: 8,
display: isInitializing ? 'none' : 'block', // 隐藏但不卸载
}}
/>
{/* loading 作为遮罩层覆盖在 video 上方 */}
{isInitializing && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
width: '100%',
height: '500px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
borderRadius: 8,
}}
>
<Spin size="large" tip="正在初始化摄像头..." />
</div>
)}
{/* 按钮等其他元素 */}
{!isInitializing && (
<Space style={{ marginTop: 24 }} size="large">
<Button ... >拍照</Button>
<Button ... >取消</Button>
</Space>
)}
</div>
)}
video 元素生命周期独立
!capturedImage
,video 就存在使用 CSS display 控制可见性
display: none
隐藏元素但不卸载loading 变成遮罩层
因为重拍时:
setCapturedImage(null)
触发 useEffectisInitializing
已经是 false
initCamera()
这个问题说明了状态更新时机的重要性:在 React 中,状态更新会触发重新渲染,如果组件结构依赖于状态,可能导致预期之外的 DOM 变化。
初始代码先设置 srcObject
,后绑定 onloadedmetadata
事件:
// ❌ 错误的顺序
videoRef.current.srcObject = stream;
videoRef.current.onloadedmetadata = () => { ... };
如果流设置得太快,元数据加载可能在事件绑定前就完成了,导致事件永远不触发,视频无法播放。
必须先绑定事件,再设置 srcObject:
// ✅ 正确的顺序
if (videoRef.current) {
// 1. 先绑定事件
videoRef.current.onloadedmetadata = async () => {
try {
if (videoRef.current) {
await videoRef.current.play();
console.log('[CameraModal] 视频开始播放');
}
} catch (playError: any) {
console.error('[CameraModal] 视频播放失败:', playError);
message.error('视频播放失败: ' + playError.message);
}
};
// 2. 再设置流
videoRef.current.srcObject = stream;
}
srcObject
设置后会立即开始加载媒体loadedmetadata
事件可能在绑定前就触发handleRetake
直接调用 initCamera()
,同时 setCapturedImage(null)
也会触发 useEffect 中的初始化:
// ❌ 导致重复初始化
const handleRetake = () => {
dispatch(setCapturedImage(null)); // 触发 useEffect
initCamera(); // 直接调用,导致重复
};
移除直接调用,让 useEffect 自动处理:
// ✅ 避免重复初始化
const handleRetake = () => {
dispatch(setCapturedImage(null));
// 不需要手动调用 initCamera(),useEffect 会自动处理
};
useEffect 的依赖包含 capturedImage
,当它变为 null
时会自动触发重新初始化。
// 压缩策略
const COMPRESSION_CONFIG = {
quality: 0.8, // JPEG 压缩质量
maxWidth: 1280, // 最大宽度
maxHeight: 720, // 最大高度
maxSize: 1024 * 1024, // 最大文件大小 1MB
};
// 检测浏览器支持
const isCameraSupported = !!(
navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia
);
// 不支持时提供文件上传
if (!isCameraSupported) {
return <FileUpload />;
}
创建 Redux Slice
实现适配器模式
创建工具函数
实现 CameraModal
实现 CameraPreview
实现 PhotoPreview
实现 API 接口
集成到主流程
Electron 适配
Cordova 适配
全面测试
性能优化
用户体验优化
风险 | 影响 | 对策 |
---|---|---|
浏览器不支持摄像头 API | 功能不可用 | 提供文件上传降级方案 |
权限被用户拒绝 | 无法拍照 | 清晰的权限说明和引导 |
网络不稳定导致上传失败 | 照片丢失 | 本地缓存 + 重试机制 |
不同环境 API 差异大 | 开发复杂 | 使用适配器模式统一接口 |
图片过大影响性能 | 上传慢、内存占用高 | 自动压缩和尺寸调整 |
风险 | 影响 | 对策 |
---|---|---|
患者隐私泄露 | 法律风险 | 加密传输、访问控制 |
照片与患者关联错误 | 医疗事故 | 严格的 UID 关联逻辑 |
拍照延迟影响急诊流程 | 效率降低 | 优化性能、提供跳过选项 |
多张照片支持
照片编辑
AI 辅助
历史照片查看
WebWorker 压缩
增量上传
缓存策略
本文档详细描述了急诊拍照功能的完整实现方案,包括:
通过本方案的实施,可以为急诊流程提供可靠的患者身份确认功能,避免患者信息混淆,提高医疗服务质量。