495 lines
12 KiB
Vue
495 lines
12 KiB
Vue
<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>
|