feat(detail): 参会人表新增 增值税及附加/摘要/现场照片 3 列 + 劳务协议展示, 角色=劳务形式, 费项改名 应发金额/个税税金/实发金额
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
unpackage/
|
||||
.hbuilderx/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="uts">
|
||||
// #ifdef APP-ANDROID || APP-HARMONY
|
||||
let firstBackTime = 0
|
||||
// #endif
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('App Launch')
|
||||
})
|
||||
|
||||
onAppShow(() => {
|
||||
console.log('App Show')
|
||||
})
|
||||
|
||||
onAppHide(() => {
|
||||
console.log('App Hide')
|
||||
})
|
||||
|
||||
// #ifdef APP-ANDROID || APP-HARMONY
|
||||
onLastPageBackPress(() => {
|
||||
console.log('App LastPageBackPress')
|
||||
if (firstBackTime == 0) {
|
||||
uni.showToast({
|
||||
title: '再按一次退出应用',
|
||||
position: 'bottom',
|
||||
})
|
||||
firstBackTime = Date.now()
|
||||
setTimeout(() => {
|
||||
firstBackTime = 0
|
||||
}, 2000)
|
||||
} else if (Date.now() - firstBackTime < 2000) {
|
||||
firstBackTime = Date.now()
|
||||
uni.exit()
|
||||
}
|
||||
})
|
||||
|
||||
onExit(() => {
|
||||
console.log('App Exit')
|
||||
})
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/*每个页面公共css */
|
||||
.uni-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.uni-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 共享相机 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, describeCameraError, type Facing } from '@/utils/camera'
|
||||
|
||||
export function useCamera(videoId: string) {
|
||||
const facing = ref<Facing>('environment')
|
||||
const captured = ref<string>('')
|
||||
const errorMsg = ref<string>('')
|
||||
|
||||
function getVideoEl(): HTMLVideoElement | null {
|
||||
return document.getElementById(videoId) as HTMLVideoElement | 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 {
|
||||
captured.value = captureFrame(video)
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
title: (err as Error).message || '截图失败',
|
||||
icon: 'none',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function retake() {
|
||||
captured.value = ''
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
// POC: 假上传 + loading 给用户完整仪式感
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 800))
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '已保存 (POC 未上传)',
|
||||
icon: 'none',
|
||||
duration: 1500,
|
||||
})
|
||||
captured.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startCamera()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const video = getVideoEl()
|
||||
if (video) {
|
||||
stopCamera(video)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
facing,
|
||||
captured,
|
||||
errorMsg,
|
||||
startCamera,
|
||||
flipCamera,
|
||||
takePhoto,
|
||||
retake,
|
||||
confirm,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||
CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title>取景拍摄</title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<style>
|
||||
/* 路由切换瞬间避免白屏 */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #0d0d0d;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="/main"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
import App from './App.uvue'
|
||||
|
||||
import { createSSRApp } from 'vue'
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "ry-h5",
|
||||
"appid": "__UNI__F1A462A",
|
||||
"description": "证件/会议拍摄预研 (H5)",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"uni-app-x": {},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-alipay": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-baidu": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"mp-toutiao": {
|
||||
"usingComponents": true
|
||||
},
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
"vueVersion": "3",
|
||||
"h5": {
|
||||
"title": "证件/会议拍摄",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "/camera/"
|
||||
},
|
||||
"publicPath": "/camera/",
|
||||
"devServer": {
|
||||
"https": false,
|
||||
"port": 8090,
|
||||
"disableHostCheck": true,
|
||||
"publicPath": "/camera/"
|
||||
},
|
||||
"sdkConfigs": {},
|
||||
"optimization": {
|
||||
"treeShaking": {
|
||||
"enable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"distribute": {
|
||||
"icons": {
|
||||
"android": {
|
||||
"hdpi": "",
|
||||
"xhdpi": "",
|
||||
"xxhdpi": "",
|
||||
"xxxhdpi": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app-android": {
|
||||
"distribute": {
|
||||
"modules": {},
|
||||
"icons": {
|
||||
"hdpi": "",
|
||||
"xhdpi": "",
|
||||
"xxhdpi": "",
|
||||
"xxxhdpi": ""
|
||||
},
|
||||
"splashScreens": {
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app-ios": {
|
||||
"distribute": {
|
||||
"modules": {},
|
||||
"icons": {},
|
||||
"splashScreens": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# nginx 配置示例 — 把这个 location 块贴进你已有的 HTTPS server { ... } 里
|
||||
#
|
||||
# 打包:
|
||||
# HBuilderX → 发行 → 网站-手机H5
|
||||
# 产物: unpackage/dist/build/h5/
|
||||
#
|
||||
# 部署:
|
||||
# sudo cp -r unpackage/dist/build/h5/ /var/www/ry-h5/
|
||||
#
|
||||
# 真机访问:
|
||||
# https://<你的域名或IP>/camera/
|
||||
|
||||
# ========== 单 location 块(粘到现有 server 里) ==========
|
||||
|
||||
location /camera/ {
|
||||
alias /var/www/ry-h5/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /camera/index.html;
|
||||
|
||||
# 长缓存静态资源(JS/CSS/SVG/PNG)
|
||||
location ~* ^/camera/static/.*\.(js|css|svg|png|jpg|jpeg|gif|woff2?)$ {
|
||||
alias /var/www/ry-h5/static/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ========== 完整 server 块参考(若你想独立跑一个 server) ==========
|
||||
|
||||
# HTTPS(用你已有的证书路径替换)
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name your-domain.com;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/your-cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/your-cert-key.pem;
|
||||
#
|
||||
# location /camera/ {
|
||||
# alias /var/www/ry-h5/;
|
||||
# index index.html;
|
||||
# try_files $uri $uri/ /camera/index.html;
|
||||
# }
|
||||
# }
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "取景拍摄",
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColor": "#0d0d0d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/signin/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "签到表取景",
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColor": "#000000",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/panorama/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "全景取景",
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColor": "#000000",
|
||||
"disableScroll": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "取景拍摄",
|
||||
"navigationBarBackgroundColor": "#000000",
|
||||
"backgroundColor": "#0d0d0d"
|
||||
},
|
||||
"uniIdRouter": {}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<view class="home">
|
||||
<view class="hero">
|
||||
<text class="hero-title">取景拍摄</text>
|
||||
<text class="hero-subtitle">选择拍摄场景</text>
|
||||
</view>
|
||||
|
||||
<view class="list">
|
||||
<view class="card" @click="goSignin">
|
||||
<view class="card-icon-wrap">
|
||||
<text class="card-icon">📄</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<text class="card-title">签到表拍摄</text>
|
||||
<text class="card-desc">A4 竖版 · 框宽 90% · 适合签到表 / 会议材料</text>
|
||||
</view>
|
||||
<text class="card-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<view class="card" @click="goPanorama">
|
||||
<view class="card-icon-wrap">
|
||||
<text class="card-icon">📷</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<text class="card-title">全景照片拍摄</text>
|
||||
<text class="card-desc">16:9 横版 · 框宽 80% · 适合合影 / 全景</text>
|
||||
</view>
|
||||
<text class="card-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer">
|
||||
<text class="footer-text">POC 阶段 · 真机测试需 HTTPS</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
function goSignin() {
|
||||
uni.navigateTo({ url: '/pages/signin/index' })
|
||||
}
|
||||
|
||||
function goPanorama() {
|
||||
uni.navigateTo({ url: '/pages/panorama/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.home {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #0d0d0d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 80rpx 40rpx 40rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
color: #FFD600;
|
||||
font-size: 56rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
display: block;
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 40rpx;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 40rpx 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid rgba(255, 214, 0, 0.25);
|
||||
}
|
||||
|
||||
.card:active {
|
||||
background: rgba(255, 214, 0, 0.12);
|
||||
border-color: rgba(255, 214, 0, 0.6);
|
||||
}
|
||||
|
||||
.card-icon-wrap {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 214, 0, 0.15);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 52rpx;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-arrow {
|
||||
color: #FFD600;
|
||||
font-size: 56rpx;
|
||||
margin-left: 16rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding: 60rpx 40rpx 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
color: #666;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<view class="camera-page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="topbar">
|
||||
<view class="back-btn" @click="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
</view>
|
||||
<text class="topbar-title">全景取景 (16:9)</text>
|
||||
<view class="placeholder" />
|
||||
</view>
|
||||
|
||||
<!-- 视频 + overlay -->
|
||||
<view class="viewport">
|
||||
<video
|
||||
id="panorama-video"
|
||||
class="video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<image
|
||||
class="frame panorama-frame"
|
||||
src="/static/overlay/panorama.svg"
|
||||
mode="widthFix"
|
||||
/>
|
||||
|
||||
<view v-if="errorMsg" class="error-layer">
|
||||
<text class="error-text">{{ errorMsg }}</text>
|
||||
<button class="retry-btn" @click="startCamera">重试</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部控制条 -->
|
||||
<view class="controls">
|
||||
<button class="ctrl-btn" @click="flipCamera">
|
||||
<text class="ctrl-icon">⟲</text>
|
||||
<text class="ctrl-label">翻转</text>
|
||||
</button>
|
||||
|
||||
<view class="shutter" @click="takePhoto">
|
||||
<view class="shutter-inner" />
|
||||
</view>
|
||||
|
||||
<view class="ctrl-btn-placeholder" />
|
||||
</view>
|
||||
|
||||
<!-- 拍照预览层 -->
|
||||
<view v-if="captured" class="preview">
|
||||
<image
|
||||
class="preview-img"
|
||||
:src="captured"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="preview-actions">
|
||||
<button class="preview-btn preview-btn-cancel" @click="retake">
|
||||
<text>重拍</text>
|
||||
</button>
|
||||
<button class="preview-btn preview-btn-confirm" @click="confirm">
|
||||
<text>使用</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { useCamera } from '@/composables/useCamera'
|
||||
|
||||
const videoId = 'panorama-video'
|
||||
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
|
||||
useCamera(videoId)
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.camera-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* ========== 顶部导航 ========== */
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 30rpx;
|
||||
padding-top: calc(20rpx + env(safe-area-inset-top));
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
color: #fff;
|
||||
font-size: 60rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 80rpx;
|
||||
}
|
||||
|
||||
/* ========== 视频取景区 ========== */
|
||||
.viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 16:9 框: 宽度 80% 视口,SVG 内置 9:16 比例 (竖屏显示) */
|
||||
.panorama-frame {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ========== 错误层 ========== */
|
||||
.error-layer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40rpx;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 16rpx;
|
||||
max-width: 80%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 12rpx 48rpx;
|
||||
background: #FFD600;
|
||||
color: #000;
|
||||
border-radius: 32rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ========== 底部控制 ========== */
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 40rpx 60rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
color: #fff;
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
color: #ccc;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.ctrl-btn-placeholder {
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.shutter {
|
||||
width: 144rpx;
|
||||
height: 144rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 6rpx solid #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 0 0 4rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.shutter:active {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.shutter-inner {
|
||||
width: 110rpx;
|
||||
height: 110rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ========== 预览层 ========== */
|
||||
.preview {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
padding: 40rpx 60rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
flex: 1;
|
||||
margin: 0 20rpx;
|
||||
padding: 24rpx 0;
|
||||
border-radius: 48rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.preview-btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.preview-btn-confirm {
|
||||
background: #FFD600;
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<view class="camera-page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="topbar">
|
||||
<view class="back-btn" @click="goBack">
|
||||
<text class="back-icon">‹</text>
|
||||
</view>
|
||||
<text class="topbar-title">签到表取景 (A4)</text>
|
||||
<view class="placeholder" />
|
||||
</view>
|
||||
|
||||
<!-- 视频 + overlay -->
|
||||
<view class="viewport">
|
||||
<video
|
||||
id="signin-video"
|
||||
class="video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<image
|
||||
class="frame signin-frame"
|
||||
src="/static/overlay/signin.svg"
|
||||
mode="widthFix"
|
||||
/>
|
||||
|
||||
<view v-if="errorMsg" class="error-layer">
|
||||
<text class="error-text">{{ errorMsg }}</text>
|
||||
<button class="retry-btn" @click="startCamera">重试</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部控制条 -->
|
||||
<view class="controls">
|
||||
<button class="ctrl-btn" @click="flipCamera">
|
||||
<text class="ctrl-icon">⟲</text>
|
||||
<text class="ctrl-label">翻转</text>
|
||||
</button>
|
||||
|
||||
<view class="shutter" @click="takePhoto">
|
||||
<view class="shutter-inner" />
|
||||
</view>
|
||||
|
||||
<view class="ctrl-btn-placeholder" />
|
||||
</view>
|
||||
|
||||
<!-- 拍照预览层 -->
|
||||
<view v-if="captured" class="preview">
|
||||
<image
|
||||
class="preview-img"
|
||||
:src="captured"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="preview-actions">
|
||||
<button class="preview-btn preview-btn-cancel" @click="retake">
|
||||
<text>重拍</text>
|
||||
</button>
|
||||
<button class="preview-btn preview-btn-confirm" @click="confirm">
|
||||
<text>使用</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { useCamera } from '@/composables/useCamera'
|
||||
|
||||
const videoId = 'signin-video'
|
||||
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
|
||||
useCamera(videoId)
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.camera-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* ========== 顶部导航 ========== */
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 30rpx;
|
||||
padding-top: calc(20rpx + env(safe-area-inset-top));
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
color: #fff;
|
||||
font-size: 60rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 80rpx;
|
||||
}
|
||||
|
||||
/* ========== 视频取景区 ========== */
|
||||
.viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* A4 框: 宽度 90% 视口,SVG 内置 1:√2 比例 */
|
||||
.signin-frame {
|
||||
width: 90%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ========== 错误层 ========== */
|
||||
.error-layer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40rpx;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 16rpx;
|
||||
max-width: 80%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 12rpx 48rpx;
|
||||
background: #FFD600;
|
||||
color: #000;
|
||||
border-radius: 32rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ========== 底部控制 ========== */
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 40rpx 60rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
color: #fff;
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
color: #ccc;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.ctrl-btn-placeholder {
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.shutter {
|
||||
width: 144rpx;
|
||||
height: 144rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 6rpx solid #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 0 0 4rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.shutter:active {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.shutter-inner {
|
||||
width: 110rpx;
|
||||
height: 110rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ========== 预览层 ========== */
|
||||
.preview {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
padding: 40rpx 60rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
flex: 1;
|
||||
margin: 0 20rpx;
|
||||
padding: 24rpx 0;
|
||||
border-radius: 48rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.preview-btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.preview-btn-confirm {
|
||||
background: #FFD600;
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"targets": [
|
||||
"H5"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 1600" preserveAspectRatio="xMidYMid meet">
|
||||
<!-- 16:9 框 — 在竖屏下显示为 9:16,宽度 80% 视口 -->
|
||||
<rect x="40" y="40" width="820" height="1520" rx="20" fill="none" stroke="#00E676" stroke-width="6" stroke-dasharray="20 12"/>
|
||||
<!-- 4 个角的定位标记 -->
|
||||
<g stroke="#FFD600" stroke-width="6" fill="none">
|
||||
<path d="M 80 80 L 80 130 M 80 80 L 130 80"/>
|
||||
<path d="M 860 80 L 860 130 M 860 80 L 810 80"/>
|
||||
<path d="M 80 1520 L 80 1470 M 80 1520 L 130 1520"/>
|
||||
<path d="M 860 1520 L 860 1470 M 860 1520 L 810 1520"/>
|
||||
</g>
|
||||
<!-- 顶部标签 (正立显示,告诉用户当前画面用途) -->
|
||||
<rect x="250" y="700" width="400" height="70" rx="35" fill="rgba(0,0,0,0.6)" stroke="#FFD600" stroke-width="3"/>
|
||||
<text x="450" y="750" text-anchor="middle" fill="#FFD600" font-size="44" font-weight="bold">全景取景 (16:9)</text>
|
||||
<!-- 中部: 当前实际显示比例提示 (正立,易读) -->
|
||||
<text x="450" y="850" text-anchor="middle" fill="rgba(255,255,255,0.7)" font-size="28">当前显示 9 : 16</text>
|
||||
<!-- 旋转 90° 提示 (视觉上暗示横屏拍摄效果更佳) -->
|
||||
<text x="450" y="1300" text-anchor="middle" fill="rgba(255,255,255,0.85)" font-size="36" stroke="rgba(0,0,0,0.5)" stroke-width="1" transform="rotate(-90 450 1300)">建议横屏拍摄 · 横屏时为 16:9</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1414" preserveAspectRatio="xMidYMid meet">
|
||||
<!-- A4 比例外框 (1 : √2,210mm × 297mm) — 宽度 90% 视口 -->
|
||||
<rect x="40" y="40" width="920" height="1334" rx="20" fill="none" stroke="#00E676" stroke-width="6" stroke-dasharray="20 12"/>
|
||||
<!-- 4 个角的定位标记 (帮助对齐纸张角) -->
|
||||
<g stroke="#FFD600" stroke-width="6" fill="none">
|
||||
<path d="M 80 80 L 80 130 M 80 80 L 130 80"/>
|
||||
<path d="M 920 80 L 920 130 M 920 80 L 870 80"/>
|
||||
<path d="M 80 1334 L 80 1284 M 80 1334 L 130 1334"/>
|
||||
<path d="M 920 1334 L 920 1284 M 920 1334 L 870 1334"/>
|
||||
</g>
|
||||
<!-- 顶部标签 -->
|
||||
<rect x="300" y="600" width="400" height="70" rx="35" fill="rgba(0,0,0,0.6)" stroke="#FFD600" stroke-width="3"/>
|
||||
<text x="500" y="650" text-anchor="middle" fill="#FFD600" font-size="44" font-weight="bold">A4 签到表</text>
|
||||
<!-- 底部提示文字 -->
|
||||
<text x="500" y="1310" text-anchor="middle" fill="#fff" font-size="36" stroke="rgba(0,0,0,0.5)" stroke-width="1">将签到表完整放入绿框</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 这里是uni-app内置的常用样式变量
|
||||
*
|
||||
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
|
||||
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||
*
|
||||
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
|
||||
*/
|
||||
|
||||
/* 颜色变量 */
|
||||
|
||||
/* 行为相关颜色 */
|
||||
$uni-color-primary: #007aff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 文字基本颜色 */
|
||||
$uni-text-color:#333;//基本色
|
||||
$uni-text-color-inverse:#fff;//反色
|
||||
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
|
||||
$uni-text-color-placeholder: #808080;
|
||||
$uni-text-color-disable:#c0c0c0;
|
||||
|
||||
/* 背景颜色 */
|
||||
$uni-bg-color:#ffffff;
|
||||
$uni-bg-color-grey:#f8f8f8;
|
||||
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
|
||||
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
|
||||
|
||||
/* 边框颜色 */
|
||||
$uni-border-color:#c8c7cc;
|
||||
|
||||
/* 尺寸变量 */
|
||||
|
||||
/* 文字尺寸 */
|
||||
$uni-font-size-sm:12px;
|
||||
$uni-font-size-base:14px;
|
||||
$uni-font-size-lg:16px;
|
||||
|
||||
/* 图片尺寸 */
|
||||
$uni-img-size-sm:20px;
|
||||
$uni-img-size-base:26px;
|
||||
$uni-img-size-lg:40px;
|
||||
|
||||
/* Border Radius */
|
||||
$uni-border-radius-sm: 2px;
|
||||
$uni-border-radius-base: 3px;
|
||||
$uni-border-radius-lg: 6px;
|
||||
$uni-border-radius-circle: 50%;
|
||||
|
||||
/* 水平间距 */
|
||||
$uni-spacing-row-sm: 5px;
|
||||
$uni-spacing-row-base: 10px;
|
||||
$uni-spacing-row-lg: 15px;
|
||||
|
||||
/* 垂直间距 */
|
||||
$uni-spacing-col-sm: 4px;
|
||||
$uni-spacing-col-base: 8px;
|
||||
$uni-spacing-col-lg: 12px;
|
||||
|
||||
/* 透明度 */
|
||||
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
|
||||
|
||||
/* 文章场景相关 */
|
||||
$uni-color-title: #2C405A; // 文章标题颜色
|
||||
$uni-font-size-title:20px;
|
||||
$uni-color-subtitle: #555555; // 二级标题颜色
|
||||
$uni-font-size-subtitle:26px;
|
||||
$uni-color-paragraph: #3F536E; // 文章段落颜色
|
||||
$uni-font-size-paragraph:15px;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 摄像头工具 (H5 only)
|
||||
*
|
||||
* - openCamera(video, facing): 申请摄像头权限并把实时流挂到 <video>
|
||||
* - stopCamera(video): 停掉所有轨道
|
||||
* - captureFrame(video): 截当前帧为 JPEG Base64
|
||||
* - describeCameraError(err): 错误码转中文提示
|
||||
*/
|
||||
export type Facing = 'user' | 'environment'
|
||||
|
||||
export async function openCamera(video: HTMLVideoElement, facing: Facing): Promise<void> {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('当前浏览器不支持摄像头访问,请升级浏览器或使用 Chrome / Safari')
|
||||
}
|
||||
|
||||
// 先停掉旧的 stream,防止多 stream 冲突
|
||||
stopCamera(video)
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: { ideal: facing },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
}
|
||||
|
||||
let stream: MediaStream
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
} catch (err) {
|
||||
// facingMode 不被支持时降级为不指定
|
||||
if ((err as DOMException)?.name === 'OverconstrainedError') {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: true,
|
||||
})
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 三重保险赋值 srcObject:
|
||||
// 1. 直接赋值 (标准做法)
|
||||
// 2. 100ms 后若 readyState 仍 0,强制 Object.defineProperty
|
||||
// 3. 还不行就 src=blobURL (老 API,兼容性最广)
|
||||
video.srcObject = stream
|
||||
console.log('[camera] 第1次赋值 srcObject, readyState:', video.readyState, 'isStream:', video.srcObject === stream)
|
||||
|
||||
// iOS 必须: 不加 playsinline 会弹原生全屏播放器
|
||||
video.setAttribute('playsinline', 'true')
|
||||
// iOS 必须: 不静音黑屏 (否则 iOS 拒绝播放)
|
||||
video.muted = true
|
||||
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
if (video.readyState < 1) {
|
||||
console.warn('[camera] srcObject 没生效,尝试 Object.defineProperty 强写')
|
||||
try {
|
||||
Object.defineProperty(video, 'srcObject', {
|
||||
value: stream,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
console.log('[camera] defineProperty 后 srcObject isStream:', video.srcObject === stream)
|
||||
} catch (e) {
|
||||
console.error('[camera] defineProperty 也失败:', e)
|
||||
}
|
||||
// 再兜底: src=blobURL
|
||||
if (video.readyState < 1) {
|
||||
console.warn('[camera] 仍 readyState 0,改用 src=blobURL')
|
||||
try {
|
||||
video.src = URL.createObjectURL(stream)
|
||||
} catch (e) {
|
||||
console.error('[camera] blobURL 兜底失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
video.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
clearTimeout(fallbackTimer)
|
||||
console.log('[camera] loadedmetadata 触发, readyState:', video.readyState, 'isStream:', video.srcObject === stream)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
// 不手动调 video.play():
|
||||
// 1. UTS 把 HTMLVideoElement.play() 类型当 void,链式 .catch 会报 undefined.catch
|
||||
// 2. <video autoplay muted playsinline> + srcObject 已让浏览器自动起流
|
||||
// 若某些浏览器不自动播放,在用户点击 shutter 等交互中再触发 play()
|
||||
}
|
||||
|
||||
export function stopCamera(video: HTMLVideoElement): void {
|
||||
const obj = video.srcObject as MediaStream | null
|
||||
if (obj) {
|
||||
obj.getTracks().forEach((t) => t.stop())
|
||||
video.srcObject = null
|
||||
}
|
||||
}
|
||||
|
||||
export function captureFrame(video: HTMLVideoElement): string {
|
||||
const w = video.videoWidth || video.clientWidth
|
||||
const h = video.videoHeight || video.clientHeight
|
||||
if (!w || !h) {
|
||||
throw new Error('视频流尚未就绪,无法截图')
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('无法获取 canvas 2D 上下文')
|
||||
ctx.drawImage(video, 0, 0, w, h)
|
||||
return canvas.toDataURL('image/jpeg', 0.85)
|
||||
}
|
||||
|
||||
export function describeCameraError(err: unknown): string {
|
||||
const name = (err as DOMException)?.name || ''
|
||||
switch (name) {
|
||||
case 'NotAllowedError':
|
||||
case 'PermissionDeniedError':
|
||||
return '请在浏览器设置中允许使用摄像头权限'
|
||||
case 'NotFoundError':
|
||||
case 'DevicesNotFoundError':
|
||||
return '未检测到摄像头设备'
|
||||
case 'NotReadableError':
|
||||
case 'TrackStartError':
|
||||
return '摄像头被其他程序占用,请关闭后重试'
|
||||
case 'OverconstrainedError':
|
||||
case 'ConstraintNotSatisfiedError':
|
||||
return '摄像头参数不支持,已自动降级'
|
||||
case 'SecurityError':
|
||||
return '请通过 HTTPS 或 localhost 访问以使用摄像头'
|
||||
default:
|
||||
return '打开摄像头失败:' + ((err as Error)?.message || String(err))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user