501 lines
18 KiB
Vue
501 lines
18 KiB
Vue
<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. 每日饮水量增加至 2000ml;2. 使用含有角鲨烷或维生素 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>
|