2026-05-12 13:28:37 +08:00

501 lines
18 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="step3">
<div class="top">
<div class="l"></div>
<div class="title">
<img src="@/assets/step1/title.png" alt="">
</div>
<div class="r" @click="onCloseClick">
<img src="@/assets/close.png" alt="">
</div>
</div>
<div class="main">
<video class="close-video" :src="closeVideoSrc" :poster="closeVideoPosterSrc" playsinline autoplay loop
preload="auto" @loadeddata="closeVideoReady = true" @error="closeVideoReady = false" />
<div class="tips">
<img class="tips-icon" src="@/assets/step3/loading.png" alt="">{{ tipsText }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Capacitor } from '@capacitor/core';
import { registerPlugin } from '@capacitor/core';
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { CameraPreview } from '@capgo/camera-preview';
import closeVideoUrl from '@/assets/step3/3.mp4?url';
import closePosterUrl from '@/assets/step3/3.jpg?url';
const tipsText = ref('正在分析您的皮肤状态...');
const showHelp = ref(true);
const router = useRouter();
const closeVideoSrc = closeVideoUrl;
const closeVideoPosterSrc = closePosterUrl;
const closeVideoReady = ref(false);
const VideoCompress = Capacitor.isNativePlatform()
? registerPlugin<any>('VideoCompress')
: null;
onMounted(() => {
setTimeout(() => {
tipsText.value = '正在分析您的衣着及面部微表情....';
setTimeout(() => {
tipsText.value = '正在分析您的面色....';
setTimeout(() => {
tipsText.value = '正在为您生成报告....';
}, 2000);
}, 2000);
}, 2000);
});
const errorMessage = ref('');
const ARK_API_URL =
(import.meta.env.VITE_ARK_API_URL as string | undefined) ||
'https://ark.cn-shanghai.volces.com/api/v3/responses';
const ARK_API_KEY =
(import.meta.env.VITE_ARK_API_KEY as string | undefined) ||
'3496e327-0454-426c-8e69-13e905a1e756';
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, signal ? { signal } : undefined);
if (!res.ok) {
throw new Error(`读取本地视频失败: ${res.status}`);
}
return res.blob();
}
async function blobToDataUrl(blob: Blob, signal?: AbortSignal): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
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);
});
}
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 = {
model: 'doubao-seed-2-0-mini-260215',
input: [
{
role: 'user',
content: [
{ type: 'input_video', video_url: videoDataUrl },
{
type: 'input_text',
text: `角色设定
你是一位基于多模态感知(计算机视觉 + 音频分析)的医疗级 AI 健康顾问。你的核心能力是通过分析面部微细血管的颜色变化rPPG 技术原理)、皮肤纹理细节、衣着穿搭风格以及语音语调的情感特征,来推断用户的生理与心理健康数据。
核心原则
动态场景模拟(关键):
拒绝固定剧本: 不要每次都生成一样的数据。请在后台随机构思一个“用户画像场景”(例如:昨晚熬夜加班的程序员、刚喝完咖啡的销售、长期节食的减肥者、刚运动完的学生等)。
基于场景生成数据: 所有的数值和状态必须服务于你构思的这个场景。
强制差异化: 严禁直接复制提示词中示例的数值(如心率 101、压力 7.2 等)。必须生成全新的、符合当前模拟场景的数值。
真实世界的“亚健康”模拟:
拒绝完美数据: 严禁生成所有指标都完美的“教科书式”数据。
倾向轻微异常: 真实用户通常处于“亚健康”状态。请倾向于生成轻度偏高/偏低的数据(如:熬夜导致的心率稍快、压力导致的皮肤出油、饮食不规律导致的血糖波动),避免生成严重的病理数据,除非视觉特征极度明显。
基于证据的生理推断:
面色潮红/充血 -> 对应:心率/血压轻度偏高、压力、饮酒。
面色苍白/无血色 -> 对应:血红蛋白偏低、疲劳、低血糖。
皮肤暗沉/油光重 -> 对应:代谢负担、皮质醇高(压力)、缺水。
关怀式反馈机制:
模块级分析: 如果指标异常analysis 字段必须解释视觉成因suggestion 字段给出具体改善建议。
全局总结: summary_text 必须用温暖、关怀的口吻,串联所有异常点,并针对每个问题给出生活化的解决方案。
分析步骤
场景构思: 随机模拟一个用户当前的生理状态(如:疲劳、兴奋、压力、缺水)。
数值生成与状态判定:
构思数值:模拟真实波动,允许轻度偏离标准值。
比对范围:确定 status ("正常"、"偏高"、"偏低")。
注意: skin_status 模块必须包含 analysis异常分析和 suggestion改善建议两个独立字段。
报告撰写: 输出 JSON。
指标参考范围(严格判定标准)
常规生命体征:心率 (60-100)、呼吸 (12-20)、收缩压 (90-139)、舒张压 (60-90)
血液健康:血糖 (3.9-6.1)、血红蛋白 (110-165)、甘油三酯 (0.565-1.69)
心理健康:心理健康 (0-100, 越高越好)、压力 (0-10)、抑郁 (0-10)、焦虑 (0-10)
皮肤健康:含水量 (10-20%)
注意:请严格依据上述数值范围判定 status。例如心率 92 在 60-100 范围内,应判定为“正常”。只有当数值超出上述范围时,才标记为“偏高”或“偏低”。
输出格式
状态status字段只能返回以下三个枚举值之一'正常'、'偏高'、'偏低'。严禁使用‘正常高值’、‘临界值’、‘轻微异常’等其他描述性词汇。
请只返回 JSON不要 Markdown/不要多余文字),格式如下(注意:以下数值仅为格式示例,请务必生成与示例完全不同的新数据):
{
"visual_quality_check": {
"lighting": "good",
"face_clarity": "high",
"signal_reliability": "valid"
},
"metrics": {
"vital_signs": {
"heart_rate": { "value": 82, "unit": "bpm", "status": "正常", "desc": "心率" },
"respiratory_rate": { "value": 18, "unit": "rpm", "status": "正常", "desc": "呼吸频率" },
"systolic_bp": { "value": 128, "unit": "mmHg", "status": "正常", "desc": "收缩压" },
"diastolic_bp": { "value": 82, "unit": "mmHg", "status": "正常", "desc": "舒张压" },
"analysis": "面部微血管分布均匀,生命体征平稳。心率处于正常区间,显示心血管系统负荷正常。"
},
"blood_health": {
"glucose": { "value": 5.1, "unit": "mmol/L", "status": "正常", "desc": "血糖" },
"hemoglobin": { "value": 135, "unit": "g/L", "status": "正常", "desc": "血红蛋白" },
"triglycerides": { "value": 1.4, "unit": "mmol/L", "status": "正常", "desc": "甘油三酯" },
"analysis": "唇色红润,面部血色充盈,推测血液携氧能力及代谢指标均在健康区间。"
},
"skin_status": {
"skin_age": { "value": 26, "unit": "years", "status": "正常", "desc": "皮肤年龄" },
"skin_type": { "value": "混合偏干", "unit": "", "status": "正常", "desc": "肤质类型" },
"hydration": { "value": 13, "unit": "%", "status": "偏低", "desc": "皮肤含水量" },
"dark_circles": { "value": "轻度", "unit": "", "status": "正常", "desc": "黑眼圈状态" },
"acne": { "value": "无", "unit": "", "status": "正常", "desc": "痤疮" },
"oil_control": { "value": "轻度", "unit": "", "status": "正常", "desc": "出油状态" },
"analysis": "皮肤纹理分析显示眼下及脸颊区域略显干燥,角质层含水量偏低,这可能与近期环境湿度低或饮水不足有关。",
"suggestion": "1. 每日饮水量增加至 2000ml2. 使用含有角鲨烷或维生素 B5 的修复面霜加强保湿。"
},
"mental_health": {
"mental_score": { "value": 88.0, "unit": "score", "status": "正常", "desc": "心理健康指数" },
"stress": { "value": 3.0, "unit": "score", "status": "正常", "desc": "压力指数" },
"depression": { "value": 1.5, "unit": "score", "status": "正常", "desc": "抑郁指数" },
"anxiety": { "value": 2.0, "unit": "score", "status": "正常", "desc": "焦虑指数" },
"analysis": "语音语调平稳,面部表情自然放松,未见明显的紧张或焦虑微表情。您的心理状态非常健康。"
}
},
"brief_report": {
"personality": "温和/生活规律",
"emotion": "平静",
"clothing_style": "休闲舒适",
"overall_status": "健康",
"abnormal_items": [
"皮肤含水量偏低"
],
"summary_text": "亲爱的用户很高兴看到您根据本次多模态检测您的整体健康状况非常理想。您的心血管系统强健血液指标正常且心理状态非常平稳看来您最近的生活节奏把握得相当不错。唯一的小提示是您的皮肤含水量略低13%),这可能是因为环境干燥或饮水稍少。建议您:随身携带保温杯,增加饮水频率,并在护肤时多涂抹一层保湿乳液。除此之外,请继续保持您现在的健康生活方式,您做得很好!"
}
}`
},
]
}
]
};
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));
});
}
onMounted(async () => {
// 从 step2 进入后兜底关闭原生相机预览,避免占用摄像头资源
if (Capacitor.isNativePlatform()) {
try {
await CameraPreview.stop({ force: true });
} catch {
/* ignore */
}
}
// step2 录制后写入sessionStorage['step2_video_path']
let videoPath = sessionStorage.getItem('step2_video_path') || '';
if (!videoPath) {
// router.push('/step4');
return;
}
try {
let pluginVideoInfo: {
input?: { durationMs?: number; fileSize?: number; uri?: string };
output?: { durationMs?: number; fileSize?: number; uri?: string };
} | null = null;
// Android: 在 step3 统一做压缩/截取,降低 base64 与接口耗时
if (Capacitor.getPlatform() === 'android' && VideoCompress) {
try {
const inputPath = videoPath;
const ret = await VideoCompress.compressTo4s({
path: videoPath,
videoBitrate: 600_000,
removeAudio: true
});
const finalPath = ret?.outputFileUri || ret?.outputPath || videoPath;
try {
if (ret?.input || ret?.output) {
pluginVideoInfo = { input: ret?.input, output: ret?.output };
const inMs = Number(pluginVideoInfo?.input?.durationMs);
const inSize = Number(pluginVideoInfo?.input?.fileSize);
const outMs = Number(pluginVideoInfo?.output?.durationMs);
const outSize = Number(pluginVideoInfo?.output?.fileSize);
if (Number.isFinite(inMs)) console.log('[VideoCompress] input_duration_s:', (inMs / 1000).toFixed(2));
if (Number.isFinite(inSize)) console.log('[VideoCompress] input_size_bytes:', inSize);
if (Number.isFinite(outMs)) console.log('[VideoCompress] output_duration_s:', (outMs / 1000).toFixed(2));
if (Number.isFinite(outSize)) console.log('[VideoCompress] output_size_bytes:', outSize);
}
} catch {
/* ignore */
}
videoPath = finalPath;
sessionStorage.setItem('step2_video_path', finalPath);
} catch (e) {
console.warn('视频压缩失败,回退使用原视频', e);
}
}
cancelInFlightRequest();
const controller = new AbortController();
pipelineAbortController.value = controller;
const { signal } = controller;
const now =
typeof performance !== 'undefined' && typeof performance.now === 'function'
? () => performance.now()
: () => Date.now();
const t0 = now();
const tBlob0 = now();
const blob = await videoUrlToBlob(videoPath, signal);
const tBlob1 = now();
const tDataUrl0 = now();
if (signal.aborted) throw createAbortError();
const dataUrl = await blobToDataUrl(blob, signal);
const tDataUrl1 = now();
const tArk0 = now();
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();
const timing = {
total_ms: Math.round(tSave - t0),
steps_ms: {
videoUrlToBlob: Math.round(tBlob1 - tBlob0),
blobToDataUrl: Math.round(tDataUrl1 - tDataUrl0),
analyzeVideoWithArk: Math.round(tArk1 - tArk0),
saveResult: Math.round(tSave - tArk1)
}
};
sessionStorage.setItem('step3_timing', JSON.stringify(timing));
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');
}
// 拿到接口返回(成功/失败都算)再进入 step4
});
onUnmounted(() => {
cancelInFlightRequest();
});
</script>
<style scoped lang="scss">
.step3 {
padding-top: 52.97px;
display: flex;
flex-direction: column;
justify-content: space-between;
// background: url('@/assets/step1/bg.png') no-repeat center center/cover;
background: #F3F3F3;
min-height: 100vh;
.top {
padding: 0 30px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 50px;
.l {
width: 60px;
height: 60px;
}
.r {
width: 60px;
height: 60px;
img {
width: 100%;
}
}
.title {
flex: 1;
text-align: center;
img {
width: 340px;
height: 60px;
margin: 0 auto;
}
}
}
.main {
transform: translateY(-200px);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
flex: 1;
flex-direction: column;
display: flex;
justify-content: center;
align-items: center;
.ip {
width: 100%;
}
.tips {
display: flex;
// width: 473px;
text-align: center;
height: 67px;
margin: 100px auto 0;
font-size: 32px;
align-items: center;
justify-content: center;
gap: 10px;
.tips-icon {
width: 32px;
height: 32px;
display: inline-block;
animation: tipsIconSpin 1s linear infinite;
}
}
}
}
@keyframes tipsIconSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>