feat(detail): 参会人表新增 增值税及附加/摘要/现场照片 3 列 + 劳务协议展示, 角色=劳务形式, 费项改名 应发金额/个税税金/实发金额

This commit is contained in:
郭庆泰
2026-08-22 16:27:54 +08:00
parent 2ae4cd3ea5
commit da7c19af6c
47 changed files with 4976 additions and 15 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
+51
View File
@@ -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>
+93
View File
@@ -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,
}
}
+31
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
import App from './App.uvue'
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
+82
View File
@@ -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": {}
}
}
}
+44
View File
@@ -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;
# }
# }
+38
View File
@@ -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": {}
}
+154
View File
@@ -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>
+299
View File
@@ -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>
+299
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
{
"targets": [
"H5"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+18
View File
@@ -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

+16
View File
@@ -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

+76
View File
@@ -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;
+138
View File
@@ -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))
}
}