2026-04-22 22:13:53 +08:00

495 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="step2">
<!-- 原生相机预览挂载点CameraPreviewOptions.parent -->
<div :id="PREVIEW_ID" class="camera-preview" />
<!-- 叠加层圆形取景框 + 关闭按钮 + 进度环 -->
<div class="camera-overlay">
<div class="back" @click="router.replace('/')"><img src="@/assets/close.png" alt=""></div>
<div class="tipsvideo" v-if="showTipsVideo">
<video
ref="tipsVideoEl"
:src="tipsVideoSrc"
muted
playsinline
autoplay
preload="auto"
@ended="onTipsEnded"
@timeupdate="onTipsTimeUpdate"
@click="tryPlayTips"
/>
</div>
<div class="reticle" aria-hidden="true">
<div class="reticle__ring reticle__ring--outer" />
<div class="reticle__ring reticle__ring--inner" />
<div class="reticle__glow" />
</div>
<div class="bottom">
<VanCircle
class="progressCircle"
:rate="progress"
v-model:current-rate="currentRate"
:speed="120"
size="84px"
:strokeWidth="50"
:layerColor="'rgba(255,255,255,0.12)'"
:color="{
'0%': 'rgba(255, 199, 126, 0.95)',
'60%': 'rgba(185, 227, 255, 0.95)',
'100%': 'rgba(255, 199, 126, 0.95)'
}"
>
<div class="progressCircle__center">
<div class="progressCircle__pct">{{ Math.round(currentRate) }}%</div>
<div class="progressCircle__label">检测中...</div>
</div>
</VanCircle>
<div class="tip">
<img src="@/assets/step2/tips.png" alt="">
</div>
<!-- <p v-if="statusText" class="hint">{{ statusText }}</p> -->
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Capacitor } from '@capacitor/core';
import { StatusBar, Style } from '@capacitor/status-bar';
import { CameraPreview } from '@capgo/camera-preview';
import { Circle as VanCircle } from 'vant';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import tips2Url from '@/assets/step2/2.mp4?url';
const PREVIEW_ID = 'step2-preview';
const router = useRouter();
const statusText = ref('');
const progress = ref(0);
const currentRate = ref(0);
let started = false;
let released = false;
let removeResizeListener: (() => void) | null = null;
const PREVIEW_ASPECT = 4 / 3; // 4:3不裁剪想更宽可改 16/9
// 需要和 CSS 里的圆心位置保持一致(.reticle { top: 30% } 以及 overlay mask circle at 50% 30%
const RETICLE_CENTER_Y = 0.3;
const tipsVideoEl = ref<HTMLVideoElement | null>(null);
const showTipsVideo = ref(true);
const tipsVideoSrc = computed(() => tips2Url);
let navigatingToStep3 = false;
function onTipsTimeUpdate() {
const el = tipsVideoEl.value;
if (!el) return;
const d = Number.isFinite(el.duration) ? el.duration : 0;
if (d <= 0) return;
const ratio = Math.max(0, Math.min(1, el.currentTime / d));
progress.value = Math.round(ratio * 100);
}
function tryPlayTips() {
const el = tipsVideoEl.value;
if (!el) return;
el.play().catch(() => {
// iOS/部分机型可能需要用户手势;这里允许用户点一下视频开始播放
});
}
function onTipsEnded() {
progress.value = 100;
// 播放结束后保留最后一帧,不隐藏视频
const el = tipsVideoEl.value;
if (el) {
try {
el.pause();
if (Number.isFinite(el.duration) && el.duration > 0) {
el.currentTime = el.duration;
}
} catch {
/* ignore */
}
}
// 进度到 100%:停止录制拿到 videoFilePath并跳转 step3
if (!navigatingToStep3) {
navigatingToStep3 = true;
setTimeout(() => {
queueMicrotask(async () => {
try {
await teardownRecorder(); // 内部会写 sessionStorage['step2_video_path']
} finally {
router.push('/step3');
}
});
}, 5000);
}
}
/** 将插件返回的 file:// 本地路径转为 Blob供上传或 ArrayBuffer */
async function videoUrlToBlob(videoUrl: string): Promise<Blob> {
const src = Capacitor.convertFileSrc(videoUrl.trim());
const res = await fetch(src);
if (!res.ok) {
throw new Error(`读取本地视频失败: ${res.status}`);
}
return res.blob();
}
async function setupNativeRecorder() {
const vw = Math.round(window.innerWidth);
const vh = Math.round(window.innerHeight);
// 让预览按比例“contain”居中避免满屏裁剪导致人脸变大
let pw = vw;
let ph = Math.round(pw * PREVIEW_ASPECT);
if (ph > vh) {
ph = vh;
pw = Math.round(ph / PREVIEW_ASPECT);
}
const px = Math.round((vw - pw) / 2);
// 让相机画面的中心点对齐取景圆心(默认在屏幕高度 40% 处)
const desiredCenterY = Math.round(vh * RETICLE_CENTER_Y);
const pyRaw = Math.round(desiredCenterY - ph / 2);
const py = Math.max(0, Math.min(vh - ph, pyRaw));
await CameraPreview.start({
parent: PREVIEW_ID,
toBack: true,
position: 'front',
enableVideoMode: true,
disableAudio: false,
// 不铺满:按比例居中显示(可能出现上下/左右留黑边)
aspectMode: 'contain',
width: pw,
height: ph,
x: px,
y: py,
storeToFile: true,
rotateWhenOrientationChanged: true,
force: true
});
await CameraPreview.startRecordVideo({});
started = true;
statusText.value = '前置摄像头录制中';
// 尺寸变化时同步更新原生预览,保持按比例居中
const onResize = async () => {
if (!Capacitor.isNativePlatform() || released) return;
try {
const vw = Math.round(window.innerWidth);
const vh = Math.round(window.innerHeight);
let pw = vw;
let ph = Math.round(pw * PREVIEW_ASPECT);
if (ph > vh) {
ph = vh;
pw = Math.round(ph / PREVIEW_ASPECT);
}
const px = Math.round((vw - pw) / 2);
const desiredCenterY = Math.round(vh * RETICLE_CENTER_Y);
const pyRaw = Math.round(desiredCenterY - ph / 2);
const py = Math.max(0, Math.min(vh - ph, pyRaw));
await CameraPreview.setPreviewSize({
width: pw,
height: ph,
x: px,
y: py
});
} catch {
/* ignore */
}
};
window.addEventListener('resize', onResize, { passive: true });
removeResizeListener = () => window.removeEventListener('resize', onResize);
}
async function teardownRecorder() {
if (!Capacitor.isNativePlatform() || released) {
return;
}
released = true;
if (removeResizeListener) {
removeResizeListener();
removeResizeListener = null;
}
try {
if (started) {
const { videoFilePath } = await CameraPreview.stopRecordVideo();
if (videoFilePath) {
// 将视频路径交给 step3 去上传/识别
sessionStorage.setItem('step2_video_path', videoFilePath);
}
}
} catch (e) {
console.error(e);
statusText.value = '停止录制或上传失败,请重试';
}
started = false;
try {
await CameraPreview.stop({ force: true });
} catch {
/* ignore */
}
}
async function back() {
router.go(-1);
}
onMounted(async () => {
released = false;
document.documentElement.classList.add('step2-camera-active');
progress.value = 0;
currentRate.value = 0;
if (!Capacitor.isNativePlatform()) {
statusText.value = '请在真机或模拟器Capacitor中打开以使用前置录像';
return;
}
// 让顶部状态栏/导航不被相机全屏覆盖
try {
await StatusBar.show();
// 让 WebView 全屏(状态栏覆盖在 WebView 上方)
await StatusBar.setOverlaysWebView({ overlay: true });
await StatusBar.setStyle({ style: Style.Light });
// 全屏叠加时保持透明,让状态栏浮在相机画面上
await StatusBar.setBackgroundColor({ color: '#00000000' });
} catch {
/* ignore */
}
try {
await setupNativeRecorder();
// 相机正常启动后再播放提示视频
tryPlayTips();
} catch (e) {
console.error(e);
statusText.value = '无法启动相机或麦克风,请检查系统权限';
}
});
onBeforeUnmount(async () => {
await teardownRecorder();
document.documentElement.classList.remove('step2-camera-active');
try {
await StatusBar.setOverlaysWebView({ overlay: true });
} catch {
/* ignore */
}
});
</script>
<style scoped lang="scss">
.step2 {
min-height: 100vh;
background: transparent;
position: relative;
overflow: hidden;
/* 核心取景区域(可按需调大/调小) */
--reticle-size: 80vw; /* 取景框外圈直径:占屏幕宽度 80% */
--focus-radius: calc(var(--reticle-size) / 2 - 14px); /* 镂空圆孔半径:跟随取景框 */
--safe-top: var(--ion-safe-area-top, env(safe-area-inset-top, 0px));
}
.camera-preview {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.camera-overlay {
position: relative;
z-index: 2;
min-height: 100vh;
pointer-events: auto;
}
.camera-overlay::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.55);
/* 中间圆形镂空iOS/WKWebView 友好) */
-webkit-mask: radial-gradient(
circle at 50% 30%,
transparent 0 var(--focus-radius),
#000 calc(var(--focus-radius) + 1px)
);
mask: radial-gradient(
circle at 50% 30%,
transparent 0 var(--focus-radius),
#000 calc(var(--focus-radius) + 1px)
);
}
.back {
position: absolute;
top: calc(20px + var(--safe-top));
left: 20px;
z-index: 4;
width: 60px;
height: 60px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18);
display: grid;
place-items: center;
img{
width: 34px;
height: 34px;
object-fit: contain;
filter: invert(1);
opacity: 0.95;
}
}
.tipsvideo{
position: absolute;
width: 250px;
height: 250px;
right: 20px;
top: 20px;
z-index: 2;
video{
border-radius: 50%;
width: 100%;
}
}
.reticle {
position: absolute;
left: 50%;
top: 30%;
transform: translate(-50%, -50%);
width: var(--reticle-size);
height: var(--reticle-size);
z-index: 2;
pointer-events: none;
}
.reticle__ring {
position: absolute;
inset: 0;
border-radius: 999px;
border: 3px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}
.reticle__ring--outer {
opacity: 0.9;
}
.reticle__ring--inner {
inset: 18px;
opacity: 0.35;
border-width: 2px;
}
.reticle__glow {
position: absolute;
inset: -14px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.25);
}
.bottom {
position: absolute;
left: 0;
right: 0;
bottom:200px;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding: 0 28px 42px;
box-sizing: border-box;
}
.zoom {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.16);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.progressCircle {
position: relative;
filter: drop-shadow(0 10px 22px rgba(0, 0, 0, 0.45));
}
.progressCircle__center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.progressCircle__pct {
font-size: 40px;
color: #fff;
line-height: 56px;
}
.progressCircle__label {
line-height: 33px;
font-size: 24px;
color: #fff;
}
.tip {
margin: 0 auto;
width: 605px;
height: 82px;
img{
width: 100%;
}
}
.tip__icon {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.28);
opacity: 0.9;
}
.hint {
margin: 0;
padding: 12px 16px;
font-size: 16px;
line-height: 1.4;
color: #fff;
text-align: center;
background: rgba(0, 0, 0, 0.35);
border-radius: 12px;
max-width: 560px;
}
</style>
<style lang="scss">
html.step2-camera-active,
html.step2-camera-active body,
html.step2-camera-active #app {
background: transparent !important;
}
</style>