第二版本
@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build:app": "npm run build && npx cap sync android && cd android && ./gradlew assembleDebug",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "cypress run",
|
||||
"test:unit": "vitest",
|
||||
|
||||
BIN
src/assets/step1/r-bg.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 47 KiB |
BIN
src/assets/step1/video-tips.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
src/assets/step2/back.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
72
src/assets/step2/face.svg
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
src/assets/step2/line.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
src/assets/step3/3.mp4
Normal file
@ -5,31 +5,17 @@
|
||||
<div class="sub-title"><img src="@/assets/step1/sub-title.png" alt=""></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="ip" ref="ipRef">
|
||||
<div class="l">
|
||||
<img src="@/assets/step1/avatars.png" alt="">
|
||||
<div class="main-top">
|
||||
<div class="ip" ref="ipRef">
|
||||
<!-- muted -->
|
||||
<video class="ip-video" src="@/assets/step1/1.mp4" autoplay playsinline webkit-playsinline preload="auto" />
|
||||
</div>
|
||||
<div class="r">
|
||||
<div class="video-tips">
|
||||
<img src="@/assets/step1/video-tips.png" alt="">
|
||||
</div>
|
||||
<img class="avatar" src="@/assets/step1/r-bg.png" alt="">
|
||||
</div>
|
||||
<van-popover
|
||||
v-model:show="showHelp"
|
||||
placement="top-start"
|
||||
:offset="[8, 0]"
|
||||
:teleport="teleportTarget"
|
||||
class="help-popover"
|
||||
>
|
||||
<div class="help-content">我可以帮您检测常规生命体<br>征和心理压力指数噢~</div>
|
||||
<template #reference>
|
||||
<span aria-hidden="true" id="help-anchor"/>
|
||||
</template>
|
||||
</van-popover>
|
||||
<!-- muted -->
|
||||
<video
|
||||
class="ip-video"
|
||||
src="@/assets/step1/1.mp4"
|
||||
autoplay
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
preload="auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom">
|
||||
@ -44,8 +30,7 @@ import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const showHelp = ref(true);
|
||||
const ipRef = ref<HTMLElement | null>(null);
|
||||
const teleportTarget = computed(() => ipRef.value ?? 'body');
|
||||
|
||||
const router = useRouter();
|
||||
const start = () => {
|
||||
router.push('/step2');
|
||||
@ -54,7 +39,7 @@ const start = () => {
|
||||
<style scoped lang="scss">
|
||||
.step1 {
|
||||
padding-top: 52.97px;
|
||||
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@ -73,9 +58,9 @@ const start = () => {
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
margin: 0 auto;
|
||||
margin: 82px auto 0;
|
||||
width: 930px;
|
||||
height: 187.57px;
|
||||
height: 124.57px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@ -86,13 +71,18 @@ const start = () => {
|
||||
.main {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
|
||||
.main-top {
|
||||
padding-top: 115px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 88px;
|
||||
}
|
||||
|
||||
/* 与 step4 的 main 同布局:居中的 ip 容器 + 左右绝对定位元素 */
|
||||
.ip {
|
||||
position: absolute;
|
||||
top: 86px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
|
||||
width: 485.04px;
|
||||
height: 1309px;
|
||||
|
||||
@ -107,52 +97,40 @@ const start = () => {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.l,
|
||||
.r {
|
||||
position: absolute;
|
||||
top: 125px;
|
||||
width: 137px;
|
||||
height: 644px;
|
||||
}
|
||||
|
||||
img {
|
||||
.r {
|
||||
position: relative;
|
||||
|
||||
.video-tips{
|
||||
top: -359px;
|
||||
left: -69px;
|
||||
position: absolute;
|
||||
width: 497px;
|
||||
height: 256px;
|
||||
img{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.l {
|
||||
left: -222px;
|
||||
}
|
||||
|
||||
.r {
|
||||
right: -222px;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
// max-width: 520px;
|
||||
font-size: 28px;
|
||||
line-height: 1.4;
|
||||
padding: 16px 18px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 最简单:把 wrapper 当作“help-anchor”,只调这里就能移动 Popover */
|
||||
:deep(.van-popover__wrapper) {
|
||||
position: absolute;
|
||||
top: 200px;
|
||||
right: 120px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
.avatar {
|
||||
width: 423px;
|
||||
height: 493.43px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.bottom {
|
||||
width: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
position: absolute;
|
||||
bottom: 102px;
|
||||
|
||||
.btn {
|
||||
margin: 0 auto;
|
||||
width: 284px;
|
||||
@ -168,52 +146,12 @@ const start = () => {
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Popover 现 teleport 到 .ip,覆盖样式仍放全局,避免 scoped 限制 -->
|
||||
<style lang="scss">
|
||||
.help-popover {
|
||||
|
||||
.van-popover__content {
|
||||
white-space: nowrap;
|
||||
// width: 520px;
|
||||
background: rgba(0, 0, 0, 1);
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 6px 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.4;
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.van-popover__arrow {
|
||||
/* 用自定义“对话框小尾巴”替换默认菱形箭头 */
|
||||
width: 64px;
|
||||
height: 56px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
bottom: -8px !important;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.van-popover__arrow::before,
|
||||
.van-popover__arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
/* 形状:上边平直 + 左下斜切的小尾巴(接近截图效果) */
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
100% 0,
|
||||
100% 38%,
|
||||
62% 38%,
|
||||
18% 100%,
|
||||
18% 38%,
|
||||
0 38%
|
||||
);
|
||||
}
|
||||
<style>
|
||||
.help-content {
|
||||
max-width: 497px;
|
||||
font-size: 28px;
|
||||
padding: 16px 18px;
|
||||
color: #fff;
|
||||
white-space: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -5,13 +5,12 @@
|
||||
|
||||
<!-- 叠加层:圆形取景框 + 关闭按钮 + 进度环 -->
|
||||
<div class="camera-overlay">
|
||||
<div class="back" @click="router.replace('/')"><img src="@/assets/close.png" alt=""></div>
|
||||
<div class="back" @click="router.replace('/')"><img src="@/assets/step2/back.png" alt=""></div>
|
||||
|
||||
<div class="tipsvideo" v-if="showTipsVideo">
|
||||
<video
|
||||
ref="tipsVideoEl"
|
||||
:src="tipsVideoSrc"
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
@ended="onTipsEnded"
|
||||
@ -24,9 +23,17 @@
|
||||
<div class="reticle__ring reticle__ring--outer" />
|
||||
<div class="reticle__ring reticle__ring--inner" />
|
||||
<div class="reticle__glow" />
|
||||
<div class="reticle__hole" :class="{ 'is-recording': isRecording }">
|
||||
<div v-if="cameraReady" class="reticle__faceWrap">
|
||||
<img class="reticle__face" :src="faceSvgUrl" alt="" />
|
||||
</div>
|
||||
<div v-if="cameraReady" class="reticle__line reticle__line--top" />
|
||||
<div v-if="cameraReady" class="reticle__line reticle__line--bottom" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="tips-text">{{ tipsText }}</div>
|
||||
<VanCircle
|
||||
class="progressCircle"
|
||||
:rate="progress"
|
||||
@ -65,14 +72,17 @@ import { Circle as VanCircle } from 'vant';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import tips2Url from '@/assets/step2/2.mp4?url';
|
||||
import faceSvgUrl from '@/assets/step2/face.svg?url';
|
||||
|
||||
const PREVIEW_ID = 'step2-preview';
|
||||
|
||||
const tipsText = ref('');
|
||||
const router = useRouter();
|
||||
const statusText = ref('');
|
||||
const progress = ref(0);
|
||||
const currentRate = ref(0);
|
||||
let started = false;
|
||||
const isRecording = ref(false);
|
||||
const cameraReady = ref(false);
|
||||
let released = false;
|
||||
let removeResizeListener: (() => void) | null = null;
|
||||
const PREVIEW_ASPECT = 4 / 3; // 4:3,不裁剪;想更宽可改 16/9
|
||||
@ -80,10 +90,20 @@ const PREVIEW_ASPECT = 4 / 3; // 4:3,不裁剪;想更宽可改 16/9
|
||||
const RETICLE_CENTER_Y = 0.3;
|
||||
|
||||
const tipsVideoEl = ref<HTMLVideoElement | null>(null);
|
||||
// 相机未授权/未启动时不要播放提示视频
|
||||
const showTipsVideo = ref(false);
|
||||
// 提示视频始终显示,但不自动播放(由用户点击触发播放)
|
||||
const showTipsVideo = ref(true);
|
||||
const tipsVideoSrc = computed(() => tips2Url);
|
||||
let navigatingToStep3 = false;
|
||||
|
||||
const changeTipsText = (arr: {text: string, time: number}[]) => {
|
||||
for (const {text, time} of arr) {
|
||||
tipsText.value = text;
|
||||
setTimeout(() => {
|
||||
tipsText.value = text;
|
||||
}, time);
|
||||
}
|
||||
}
|
||||
|
||||
function onTipsTimeUpdate() {
|
||||
const el = tipsVideoEl.value;
|
||||
if (!el) return;
|
||||
@ -99,6 +119,7 @@ function tryPlayTips() {
|
||||
el.play().catch(() => {
|
||||
// iOS/部分机型可能需要用户手势;这里允许用户点一下视频开始播放
|
||||
});
|
||||
changeTipsText([{text: '请正视镜头', time: 0}, {text: '请向右转头', time: 2000}, {text: '请向左转头', time: 4000}]);
|
||||
}
|
||||
|
||||
function onTipsEnded() {
|
||||
@ -142,6 +163,7 @@ async function videoUrlToBlob(videoUrl: string): Promise<Blob> {
|
||||
}
|
||||
|
||||
async function setupNativeRecorder() {
|
||||
cameraReady.value = false;
|
||||
const vw = Math.round(window.innerWidth);
|
||||
const vh = Math.round(window.innerHeight);
|
||||
// 让预览按比例“contain”居中,避免满屏裁剪导致人脸变大
|
||||
@ -176,6 +198,8 @@ async function setupNativeRecorder() {
|
||||
|
||||
await CameraPreview.startRecordVideo({});
|
||||
started = true;
|
||||
isRecording.value = true;
|
||||
cameraReady.value = true;
|
||||
statusText.value = '前置摄像头录制中';
|
||||
|
||||
// 尺寸变化时同步更新原生预览,保持按比例居中
|
||||
@ -213,6 +237,8 @@ async function teardownRecorder() {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
isRecording.value = false;
|
||||
cameraReady.value = false;
|
||||
if (removeResizeListener) {
|
||||
removeResizeListener();
|
||||
removeResizeListener = null;
|
||||
@ -247,7 +273,8 @@ onMounted(async () => {
|
||||
document.documentElement.classList.add('step2-camera-active');
|
||||
progress.value = 0;
|
||||
currentRate.value = 0;
|
||||
showTipsVideo.value = false;
|
||||
showTipsVideo.value = true;
|
||||
cameraReady.value = false;
|
||||
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
statusText.value = '请在真机或模拟器(Capacitor)中打开以使用前置录像';
|
||||
@ -268,14 +295,15 @@ onMounted(async () => {
|
||||
|
||||
try {
|
||||
await setupNativeRecorder();
|
||||
// 相机正常启动后再显示/播放提示视频
|
||||
showTipsVideo.value = true;
|
||||
// 相机启动成功后自动播放提示视频
|
||||
await nextTick();
|
||||
tryPlayTips();
|
||||
//
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusText.value = '无法启动相机或麦克风,请检查系统权限';
|
||||
showTipsVideo.value = false;
|
||||
showTipsVideo.value = true;
|
||||
cameraReady.value = false;
|
||||
const el = tipsVideoEl.value;
|
||||
if (el) {
|
||||
try {
|
||||
@ -330,7 +358,7 @@ onBeforeUnmount(async () => {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
/* 中间圆形镂空(iOS/WKWebView 友好) */
|
||||
-webkit-mask: radial-gradient(
|
||||
circle at 50% 30%,
|
||||
@ -351,17 +379,9 @@ onBeforeUnmount(async () => {
|
||||
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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
}
|
||||
@ -415,6 +435,88 @@ onBeforeUnmount(async () => {
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.reticle__hole {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(var(--focus-radius) * 2);
|
||||
height: calc(var(--focus-radius) * 2);
|
||||
border-radius: 999px;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reticle__faceWrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reticle__face {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48%;
|
||||
object-fit: contain;
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reticle__line {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
width: 135%;
|
||||
height: 46px;
|
||||
background: url('@/assets/step2/line.png') no-repeat center/cover;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reticle__line--top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.reticle__line--bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.reticle__hole.is-recording .reticle__line--top {
|
||||
animation: reticleLineDownUp 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.reticle__hole.is-recording .reticle__line--bottom {
|
||||
animation: reticleLineUpDown 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes reticleLineDownUp {
|
||||
0% {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, calc(var(--focus-radius) * 2 - 46px));
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reticleLineUpDown {
|
||||
0% {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, calc((var(--focus-radius) * 2 - 46px) * -1));
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@ -427,6 +529,11 @@ onBeforeUnmount(async () => {
|
||||
gap: 18px;
|
||||
padding: 0 28px 42px;
|
||||
box-sizing: border-box;
|
||||
.tips-text{
|
||||
color: #fff;
|
||||
font-size: 40px;
|
||||
margin-bottom: 82px;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom {
|
||||
@ -467,7 +574,7 @@ onBeforeUnmount(async () => {
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin: 0 auto;
|
||||
margin: 148px auto 0;
|
||||
width: 605px;
|
||||
height: 82px;
|
||||
img{
|
||||
|
||||
@ -5,10 +5,19 @@
|
||||
<div class="title">
|
||||
<img src="@/assets/step1/title.png" alt="">
|
||||
</div>
|
||||
<div class="r" @click="router.replace('/')"><img src="@/assets/close.png" alt=""></div>
|
||||
<div class="r" @click="onCloseClick">
|
||||
<img src="@/assets/close.png" alt=""></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<img class="ip" src="@/assets/step3/main.png" alt="">
|
||||
<video
|
||||
class="close-video"
|
||||
src="@/assets/step3/3.mp4"
|
||||
|
||||
playsinline
|
||||
autoplay
|
||||
loop
|
||||
preload="auto"
|
||||
/>
|
||||
<div class="tips">
|
||||
<img class="tips-icon" src="@/assets/step3/loading.png" alt="">正在分析你的身体状态…
|
||||
</div>
|
||||
@ -19,7 +28,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const showHelp = ref(true);
|
||||
@ -35,25 +44,82 @@ const ARK_API_KEY =
|
||||
(import.meta.env.VITE_ARK_API_KEY as string | undefined) ||
|
||||
'3496e327-0454-426c-8e69-13e905a1e756';
|
||||
|
||||
async function videoUrlToBlob(videoUrl: string): Promise<Blob> {
|
||||
function createAbortError() {
|
||||
// 统一用和 fetch abort 一致的错误形态
|
||||
const err = new Error('Aborted');
|
||||
(err as any).name = 'AbortError';
|
||||
return err;
|
||||
}
|
||||
|
||||
async function videoUrlToBlob(videoUrl: string, signal?: AbortSignal): Promise<Blob> {
|
||||
const src = Capacitor.convertFileSrc(videoUrl.trim());
|
||||
const res = await fetch(src);
|
||||
const res = await fetch(src, signal ? { signal } : undefined);
|
||||
if (!res.ok) {
|
||||
throw new Error(`读取本地视频失败: ${res.status}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
async function blobToDataUrl(blob: Blob, signal?: AbortSignal): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('读取视频失败'));
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
reader.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(createAbortError());
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) return onAbort();
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
reader.onerror = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error('读取视频失败'));
|
||||
};
|
||||
reader.onload = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(String(reader.result || ''));
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function analyzeVideoWithArk(videoDataUrl: string) {
|
||||
const pipelineAbortController = ref<AbortController | null>(null);
|
||||
const isLeaving = ref(false);
|
||||
|
||||
function cancelInFlightRequest() {
|
||||
const c = pipelineAbortController.value;
|
||||
if (c) {
|
||||
try {
|
||||
c.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
pipelineAbortController.value = null;
|
||||
}
|
||||
|
||||
function onCloseClick() {
|
||||
isLeaving.value = true;
|
||||
cancelInFlightRequest();
|
||||
router.replace('/');
|
||||
}
|
||||
|
||||
async function analyzeVideoWithArk(videoDataUrl: string, signal?: AbortSignal) {
|
||||
if (!ARK_API_KEY) throw new Error('未设置 VITE_ARK_API_KEY');
|
||||
|
||||
const requestBody: any = {
|
||||
@ -65,79 +131,69 @@ async function analyzeVideoWithArk(videoDataUrl: string) {
|
||||
{ type: 'input_video', video_url: videoDataUrl },
|
||||
{
|
||||
type: 'input_text',
|
||||
text: `角色设定
|
||||
你是一位基于计算机视觉的医疗级AI分析师。你的核心能力是通过分析面部微细血管的颜色变化(rPPG技术原理)、皮肤纹理细节、微表情特征来推断生理数据。
|
||||
|
||||
核心原则:拒绝凭空捏造
|
||||
- 基于证据:每一个数据结论必须基于视频中的视觉特征。
|
||||
- 异常检测:如光线过暗/人脸模糊/遮挡严重/帧率过低导致无法提取有效信号,必须标记为 invalid,严禁编造数据。
|
||||
- 逻辑自洽:数据必须符合生理常识。
|
||||
|
||||
分析步骤
|
||||
1) 视觉特征提取:前额/脸颊颜色波动(心率/血压)、眼周嘴角纹理(皮肤年龄)、眉间/眨眼(压力)。
|
||||
2) 数值估算与校验:对照绝对极限表,超出范围直接 invalid。
|
||||
3) 报告生成:输出 JSON。
|
||||
|
||||
指标参考与极限表(超出即无效)
|
||||
心率 40-180 bpm;呼吸 8-40 rpm;收缩压 80-200;舒张压 50-120;血糖 3.0-15.0;血红蛋白 90-180;甘油三酯 0.4-5.0;皮肤年龄 5-90;压力/焦虑 0-10。
|
||||
|
||||
输出格式
|
||||
请只返回 JSON(不要 Markdown/不要多余文字),格式如下:
|
||||
{
|
||||
"visual_quality_check": {
|
||||
"lighting": "good",
|
||||
"face_clarity": "high",
|
||||
"signal_reliability": "valid"
|
||||
},
|
||||
"metrics": {
|
||||
"vital_signs": {
|
||||
"heart_rate": { "value": 78, "unit": "bpm", "status": "normal", "desc": "心率" },
|
||||
"respiratory_rate": { "value": 16, "unit": "rpm", "status": "normal", "desc": "呼吸频率" },
|
||||
"systolic_bp": { "value": 125, "unit": "mmHg", "status": "normal", "desc": "收缩压" },
|
||||
"diastolic_bp": { "value": 82, "unit": "mmHg", "status": "normal", "desc": "舒张压" }
|
||||
},
|
||||
"blood_health": {
|
||||
"glucose": { "value": 5.4, "unit": "mmol/L", "status": "normal", "desc": "血糖" },
|
||||
"hemoglobin": { "value": 135, "unit": "g/L", "status": "normal", "desc": "血红蛋白" },
|
||||
"triglycerides": { "value": 1.2, "unit": "mmol/L", "status": "normal", "desc": "甘油三酯" }
|
||||
},
|
||||
"skin_status": {
|
||||
"skin_age": { "value": 26, "unit": "years", "status": "normal", "desc": "皮肤年龄" }
|
||||
},
|
||||
"mental_health": {
|
||||
"mental_score": { "value": 80, "unit": "score", "status": "normal", "desc": "心理健康指数" },
|
||||
"stress": { "value": 4, "unit": "score", "status": "normal", "desc": "压力指数" },
|
||||
"depression": { "value": 2, "unit": "score", "status": "normal", "desc": "抑郁指数" },
|
||||
"anxiety": { "value": 3, "unit": "score", "status": "normal", "desc": "焦虑指数" }
|
||||
}
|
||||
},
|
||||
"brief_report": {
|
||||
"personality": "阳光自信",
|
||||
"emotion": "高兴",
|
||||
"overall_status": "优秀",
|
||||
"abnormal_items": [],
|
||||
"summary_text": "检测显示您的生理机能处于极佳状态,皮肤状况良好,心理压力较低,整体呈现出阳光自信的状态。"
|
||||
}
|
||||
}`
|
||||
text: `角色设定 你是一位基于计算机视觉的医疗级AI分析师。你的核心能力是通过分析面部微细血管的颜色变化(rPPG技术原理)、皮肤纹理细节、微表情特征来推断生理数据。 核心原则:拒绝凭空捏造 - 基于证据:每一个数据结论必须基于视频中的视觉特征。 - 异常检测:如光线过暗/人脸模糊/遮挡严重/帧率过低导致无法提取有效信号,必须标记为 invalid,严禁编造数据。 - 逻辑自洽:数据必须符合生理常识。 分析步骤 1) 视觉特征提取:前额/脸颊颜色波动(心率/血压)、眼周嘴角纹理(皮肤年龄)、眉间/眨眼(压力)。 2) 数值估算与校验:对照参考范围,判断状态。 3) 报告生成:输出 JSON。 指标参考范围与状态判断规则(请严格参照) 1. 常规生命体征:心率(60-100次/分)、呼吸频率(12-20次/分)、收缩压(90-139mmHg)、舒张压(60-90mmHg) 2. 血液健康指标:血糖(3.9-6.1mmol/L)、血红蛋白(110-165g/L)、甘油三酯(0.565-1.69mmol/L) 3. 心理健康指数:心理健康指数(0-100分,越高越好)、压力指数(0-10分,越低越好)、抑郁指数(0-10分,越低越好)、焦虑指数(0-10分,越低越好) —— 注意:此分类下的 value 需保留一位小数。 4. 皮肤健康指标:皮肤年龄(数值)、肤质类型(干性/中性/油性/混合型)、皮肤含水量(10-20%)、黑眼圈状态(轻度/中度/重度)、痤疮(无/少量/大量)、出油状态(轻度/中度/重度) 状态(status)定义:所有指标 status 必须统一为 "正常"、"偏高" 或 "偏低" 三个状态之一。 输出格式 请只返回 JSON(不要 Markdown/不要多余文字),格式如下: { "visual_quality_check": { "lighting": "good", "face_clarity": "high", "signal_reliability": "valid" }, "metrics": { "vital_signs": { "heart_rate": { "value": 78, "unit": "bpm", "status": "正常", "desc": "心率" }, "respiratory_rate": { "value": 16, "unit": "rpm", "status": "正常", "desc": "呼吸频率" }, "systolic_bp": { "value": 125, "unit": "mmHg", "status": "正常", "desc": "收缩压" }, "diastolic_bp": { "value": 82, "unit": "mmHg", "status": "正常", "desc": "舒张压" }, "analysis": "您的生命体征平稳,心肺功能处于良好状态。建议继续保持规律作息,适当进行有氧运动以维持心血管健康。" }, "blood_health": { "glucose": { "value": 5.4, "unit": "mmol/L", "status": "正常", "desc": "血糖" }, "hemoglobin": { "value": 135, "unit": "g/L", "status": "正常", "desc": "血红蛋白" }, "triglycerides": { "value": 1.2, "unit": "mmol/L", "status": "正常", "desc": "甘油三酯" }, "analysis": "血液代谢指标显示您的代谢功能正常,无高血糖或高血脂风险。建议保持均衡饮食,减少高糖高油摄入。" }, "skin_status": { "skin_age": { "value": 26, "unit": "years", "status": "正常", "desc": "皮肤年龄" }, "skin_type": { "value": "混合性", "unit": "", "status": "正常", "desc": "肤质类型" }, "hydration": { "value": 15, "unit": "%", "status": "正常", "desc": "皮肤含水量" }, "dark_circles": { "value": "轻度", "unit": "", "status": "正常", "desc": "黑眼圈状态" }, "acne": { "value": "无", "unit": "", "status": "正常", "desc": "痤疮" }, "oil_control": { "value": "轻度", "unit": "", "status": "正常", "desc": "出油状态" }, "analysis": "您的皮肤年龄与实际年龄相符,含水量适中。T区可能有轻微出油,建议加强控油保湿,注意眼部休息以改善轻度黑眼圈。" }, "mental_health": { "mental_score": { "value": 80.5, "unit": "score", "status": "正常", "desc": "心理健康指数" }, "stress": { "value": 4.0, "unit": "score", "status": "正常", "desc": "压力指数" }, "depression": { "value": 2.0, "unit": "score", "status": "正常", "desc": "抑郁指数" }, "anxiety": { "value": 3.0, "unit": "score", "status": "正常", "desc": "焦虑指数" }, "analysis": "您的心理状态整体积极阳光,抗压能力较强。虽然存在轻微的生活压力,但处于可控范围,建议通过冥想或兴趣爱好进一步释放压力。" } }, "brief_report": { "personality": "阳光自信", "emotion": "高兴", "overall_status": "优秀", "abnormal_items": [], "summary_text": "根据您本次的检测情况,我推测您应该是一位“阳光自信性格”的人,您当前的心情是“高兴Happy”,您当前的身体状况综合说是“优秀”的,但需要注意您当前的“压力指数”(4.0)“正常”。请点击查看详细报告,看看您本轮的检测结果吧~" } }`
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const res = await fetch(ARK_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ARK_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
return await new Promise<any>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
xhr.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(createAbortError());
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) return onAbort();
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
xhr.open('POST', ARK_API_URL, true);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${ARK_API_KEY}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.responseType = 'json';
|
||||
|
||||
xhr.onload = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
const ok = xhr.status >= 200 && xhr.status < 300;
|
||||
const body = xhr.response ?? null;
|
||||
if (!ok) {
|
||||
const message = (body as any)?.error?.message || xhr.status;
|
||||
reject(new Error(`API请求失败: ${message}`));
|
||||
return;
|
||||
}
|
||||
resolve(body);
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error('网络错误'));
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
onAbort();
|
||||
};
|
||||
|
||||
xhr.send(JSON.stringify(requestBody));
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(`API请求失败: ${err?.error?.message || res.status}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@ -149,6 +205,11 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
cancelInFlightRequest();
|
||||
const controller = new AbortController();
|
||||
pipelineAbortController.value = controller;
|
||||
const { signal } = controller;
|
||||
|
||||
const now =
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? () => performance.now()
|
||||
@ -156,16 +217,19 @@ onMounted(async () => {
|
||||
const t0 = now();
|
||||
|
||||
const tBlob0 = now();
|
||||
const blob = await videoUrlToBlob(videoPath);
|
||||
const blob = await videoUrlToBlob(videoPath, signal);
|
||||
const tBlob1 = now();
|
||||
|
||||
const tDataUrl0 = now();
|
||||
const dataUrl = await blobToDataUrl(blob);
|
||||
if (signal.aborted) throw createAbortError();
|
||||
const dataUrl = await blobToDataUrl(blob, signal);
|
||||
const tDataUrl1 = now();
|
||||
|
||||
const tArk0 = now();
|
||||
const result = await analyzeVideoWithArk(dataUrl);
|
||||
if (signal.aborted) throw createAbortError();
|
||||
const result = await analyzeVideoWithArk(dataUrl, signal);
|
||||
const tArk1 = now();
|
||||
if (isLeaving.value) return;
|
||||
|
||||
sessionStorage.setItem('step2_ark_result', JSON.stringify(result));
|
||||
const tSave = now();
|
||||
@ -183,6 +247,8 @@ onMounted(async () => {
|
||||
|
||||
router.push('/step4');
|
||||
} catch (e: any) {
|
||||
if (isLeaving.value) return;
|
||||
if (e?.name === 'AbortError') return;
|
||||
console.error(e);
|
||||
errorMessage.value = e?.message || '识别失败';
|
||||
router.push('/step1');
|
||||
@ -192,6 +258,10 @@ onMounted(async () => {
|
||||
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelInFlightRequest();
|
||||
});
|
||||
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.step3 {
|
||||
|
||||
@ -136,6 +136,9 @@ const list3 = ref<any[]>([]);
|
||||
const arkResult = sessionStorage.getItem('step2_ark_result');
|
||||
if (arkResult) {
|
||||
const result = JSON.parse(arkResult);
|
||||
|
||||
console.log(result);
|
||||
|
||||
// summaryText.value = result?.output[0]?.summary[0]?.text;
|
||||
// console.log(summaryText.value); //文本
|
||||
data.value = JSON.parse(result?.output[1]?.content[0]?.text);
|
||||
|
||||