Files
guoju0808/ry-h5/composables/useCamera.ts
T
郭庆泰 0c882ae846 feat: 本地 Java 发票 OCR + 策划方案多角色开放 + H5 相机脱敏修复
- OCR: 进程内 PaddleOCR ONNX Runtime 识别替代远程 Python 微服务
  (新增 ocr/core/service/config 引擎, 删 OcrClient/OcrConfig, PDF 文本层优先 + QR 快路径)
- 投稿: 项目设计投稿 → 项目策划方案, doctor/executor/sponsor 三角色开放
  (BizProjectPlan 按非 admin/manager 隔离, 复用 Submissions.vue, 设计文件改 OssFileUploader)
- H5: <video> 去掉 autoplay + safePlay 重试, 穿透 uni-app wrapper 找原生 video
- 脱敏: 签到表高斯模糊由横向条带改为全高竖条带 (20%~50% 宽度)
- 域名: ringdoctor.com → risingdoctor.com
2026-08-24 19:23:53 +08:00

149 lines
3.8 KiB
TypeScript

/**
* 共享相机 composable (H5 only)
*
* 用法: const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } = useCamera('my-video-id')
*
* - 自动在 onMounted 启动摄像头
* - 自动在 onBeforeUnmount 停止摄像头
* - 返回 ref 和方法,业务页只需负责 UI 布局
*/
import { ref, onMounted, onBeforeUnmount } from 'vue'
import {
openCamera,
stopCamera,
captureFrame,
captureFrameWithBlur,
describeCameraError,
type Facing,
} from '@/utils/camera'
import { uploadCameraPhoto } from '@/utils/upload'
/** 条带高斯模糊配置 (签到表脱敏用): 对截图竖直 [start, end] 比例区间做高斯模糊 */
export type BlurConfig = {
start: number
end: number
radius: number
}
export function useCamera(videoId: string, blur?: BlurConfig) {
const facing = ref<Facing>('environment')
const captured = ref<string>('')
const blurred = ref<string>('')
const errorMsg = ref<string>('')
function getVideoEl(): HTMLVideoElement | null {
const el = document.getElementById(videoId) as HTMLElement | null
if (!el) return null
// uni-app H5 把 <video> 编译成 <uni-video> Vue 组件时, getElementById
// 拿到的是 wrapper, 它的 srcObject setter 会透传给内部原生 <video>,
// 但 readyState / play() 不一定透传. 用 readyState 是否 number 判断是否原生.
if (el.tagName === 'VIDEO' && typeof (el as any).readyState === 'number') {
return el as HTMLVideoElement
}
// wrapper 场景: 内部包了一个原生 <video>
const inner = el.querySelector('video') as HTMLVideoElement | null
if (inner && typeof inner.readyState === 'number') {
console.log('[camera] 穿透 uni-app wrapper, 找到内部原生 <video>')
return inner
}
// 最后一搏: 找页面上第一个原生 <video>
const fallback = document.querySelector('video') as HTMLVideoElement | null
if (fallback) {
console.log('[camera] 全局兜底找到第一个原生 <video>')
return fallback
}
return null
}
async function startCamera() {
errorMsg.value = ''
const video = getVideoEl()
if (!video) {
errorMsg.value = '视频元素未找到'
return
}
try {
await openCamera(video, facing.value)
} catch (err) {
errorMsg.value = describeCameraError(err)
console.error('[camera]', err)
}
}
function flipCamera() {
facing.value = facing.value === 'environment' ? 'user' : 'environment'
startCamera()
}
async function takePhoto() {
const video = getVideoEl()
if (!video) return
try {
if (blur) {
const r = captureFrameWithBlur(video, blur.start, blur.end, blur.radius)
captured.value = r.sharp
blurred.value = r.blurred
} else {
captured.value = captureFrame(video)
blurred.value = ''
}
} catch (err) {
uni.showToast({
title: (err as Error).message || '截图失败',
icon: 'none',
})
}
}
function retake() {
captured.value = ''
blurred.value = ''
}
async function confirm() {
if (!captured.value) return
uni.showLoading({ title: '上传中...' })
try {
// 直传 OSS + 回传 URL 到后端 (公开端点, 无需登录)
// 签到表额外上传一张高斯模糊版 (blurred) 作为 extraOssUrl, sponsor 只看这个
await uploadCameraPhoto(captured.value, blurred.value || '')
uni.hideLoading()
uni.showToast({
title: '已上传',
icon: 'success',
duration: 1500,
})
captured.value = ''
blurred.value = ''
} catch (err) {
uni.hideLoading()
uni.showToast({
title: (err as Error).message || '上传失败',
icon: 'none',
duration: 2500,
})
}
}
onMounted(() => {
startCamera()
})
onBeforeUnmount(() => {
const video = getVideoEl()
if (video) {
stopCamera(video)
}
})
return {
facing,
captured,
errorMsg,
startCamera,
flipCamera,
takePhoto,
retake,
confirm,
}
}