邀请发送 (菜单暂隐藏, 待确认参数见 发送邀请-待确认参数.md): - 后端: BizInvite / BizInviteRecipient + Controller/Service/Mapper/XML - 邮件: InviteMailSender (spring-boot-starter-mail SMTP) + application*.yml 邮件配置 - 上传进度: UploadProgressRegistry + UploadProgressController - 前端: InviteList / InviteNew / InviteDetail / InviteView + api/business/invite.js - 原型: proto/html/components/invite-detail / new-invitation / send-invitation 登录/账号: - 首次设密: /getInfo 返回 isPasswordEmpty, SysProfileController 密码为空时跳过旧密码校验, ForcePasswordDialog 强制弹窗 - 姓名单一可信源 resolveDisplayName: doctor→biz_expert.name, sponsor/executor→biz_person.name, 其余回退 nick_name - OA compliance 门禁改为按手机号查 ecology 视图 (不再限定 manager/leader) 其它: - OSS zip 在线查看 (列清单+取单文件, 公开只读) + SecurityConfig permitAll - doctor 项目详情 ProjectDetail.vue - 数据库/测试/设计文档 (md) 入库
197 lines
5.0 KiB
TypeScript
197 lines
5.0 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, uploadCameraPhotos } 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>('')
|
|
// 连拍多张 (签到表): 每张 = sharp + 对应的高斯模糊版, 攒批后一次提交
|
|
const shots = ref<string[]>([])
|
|
const blurredShots = 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
/** 连拍: 把当前这张加入批次, 清空取景回相机 (继续拍下一张) */
|
|
function addShot() {
|
|
if (!captured.value) return
|
|
shots.value.push(captured.value)
|
|
blurredShots.value.push(blurred.value || '')
|
|
captured.value = ''
|
|
blurred.value = ''
|
|
}
|
|
|
|
/** 连拍: 删除批次里第 index 张 */
|
|
function removeShot(index: number) {
|
|
shots.value.splice(index, 1)
|
|
blurredShots.value.splice(index, 1)
|
|
}
|
|
|
|
/** 连拍: 把批次里所有照片一次性直传 OSS + 回传后端 */
|
|
async function submitAll() {
|
|
if (!shots.value.length) return
|
|
uni.showLoading({ title: '上传中...' })
|
|
try {
|
|
await uploadCameraPhotos(
|
|
shots.value.map((s, i) => ({ sharp: s, blurred: blurredShots.value[i] || '' }))
|
|
)
|
|
uni.hideLoading()
|
|
uni.showToast({
|
|
title: '已上传',
|
|
icon: 'success',
|
|
duration: 1500,
|
|
})
|
|
shots.value = []
|
|
blurredShots.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,
|
|
shots,
|
|
addShot,
|
|
removeShot,
|
|
submitAll,
|
|
}
|
|
} |