第二版本

This commit is contained in:
mzhang93 2026-04-27 10:14:16 +08:00
parent 4b9f936347
commit 28e04e2607
13 changed files with 401 additions and 210 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

BIN
src/assets/step2/back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

72
src/assets/step2/face.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 134 KiB

BIN
src/assets/step2/line.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

BIN
src/assets/step3/3.mp4 Normal file

Binary file not shown.

View 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>

View File

@ -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{

View File

@ -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 {

View File

@ -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);