第二版本
@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
|
"build:app": "npm run build && npx cap sync android && cd android && ./gradlew assembleDebug",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:e2e": "cypress run",
|
"test:e2e": "cypress run",
|
||||||
"test:unit": "vitest",
|
"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 class="sub-title"><img src="@/assets/step1/sub-title.png" alt=""></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="ip" ref="ipRef">
|
<div class="main-top">
|
||||||
<div class="l">
|
<div class="ip" ref="ipRef">
|
||||||
<img src="@/assets/step1/avatars.png" alt="">
|
<!-- 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>
|
</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>
|
</div>
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
@ -44,8 +30,7 @@ import { computed, ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const showHelp = ref(true);
|
const showHelp = ref(true);
|
||||||
const ipRef = ref<HTMLElement | null>(null);
|
|
||||||
const teleportTarget = computed(() => ipRef.value ?? 'body');
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const start = () => {
|
const start = () => {
|
||||||
router.push('/step2');
|
router.push('/step2');
|
||||||
@ -54,7 +39,7 @@ const start = () => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.step1 {
|
.step1 {
|
||||||
padding-top: 52.97px;
|
padding-top: 52.97px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -73,9 +58,9 @@ const start = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sub-title {
|
.sub-title {
|
||||||
margin: 0 auto;
|
margin: 82px auto 0;
|
||||||
width: 930px;
|
width: 930px;
|
||||||
height: 187.57px;
|
height: 124.57px;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -86,13 +71,18 @@ const start = () => {
|
|||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
.main-top {
|
||||||
|
padding-top: 115px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 与 step4 的 main 同布局:居中的 ip 容器 + 左右绝对定位元素 */
|
/* 与 step4 的 main 同布局:居中的 ip 容器 + 左右绝对定位元素 */
|
||||||
.ip {
|
.ip {
|
||||||
position: absolute;
|
|
||||||
top: 86px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, 0);
|
|
||||||
width: 485.04px;
|
width: 485.04px;
|
||||||
height: 1309px;
|
height: 1309px;
|
||||||
|
|
||||||
@ -107,52 +97,40 @@ const start = () => {
|
|||||||
object-fit: cover;
|
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%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.l {
|
.avatar {
|
||||||
left: -222px;
|
width: 423px;
|
||||||
}
|
height: 493.43px;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 102px;
|
bottom: 102px;
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 284px;
|
width: 284px;
|
||||||
@ -168,52 +146,12 @@ const start = () => {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Popover 现 teleport 到 .ip,覆盖样式仍放全局,避免 scoped 限制 -->
|
<style>
|
||||||
<style lang="scss">
|
.help-content {
|
||||||
.help-popover {
|
max-width: 497px;
|
||||||
|
font-size: 28px;
|
||||||
.van-popover__content {
|
padding: 16px 18px;
|
||||||
white-space: nowrap;
|
color: #fff;
|
||||||
// width: 520px;
|
white-space: wrap;
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@ -5,13 +5,12 @@
|
|||||||
|
|
||||||
<!-- 叠加层:圆形取景框 + 关闭按钮 + 进度环 -->
|
<!-- 叠加层:圆形取景框 + 关闭按钮 + 进度环 -->
|
||||||
<div class="camera-overlay">
|
<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">
|
<div class="tipsvideo" v-if="showTipsVideo">
|
||||||
<video
|
<video
|
||||||
ref="tipsVideoEl"
|
ref="tipsVideoEl"
|
||||||
:src="tipsVideoSrc"
|
:src="tipsVideoSrc"
|
||||||
muted
|
|
||||||
playsinline
|
playsinline
|
||||||
preload="auto"
|
preload="auto"
|
||||||
@ended="onTipsEnded"
|
@ended="onTipsEnded"
|
||||||
@ -24,9 +23,17 @@
|
|||||||
<div class="reticle__ring reticle__ring--outer" />
|
<div class="reticle__ring reticle__ring--outer" />
|
||||||
<div class="reticle__ring reticle__ring--inner" />
|
<div class="reticle__ring reticle__ring--inner" />
|
||||||
<div class="reticle__glow" />
|
<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>
|
||||||
|
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
|
<div class="tips-text">{{ tipsText }}</div>
|
||||||
<VanCircle
|
<VanCircle
|
||||||
class="progressCircle"
|
class="progressCircle"
|
||||||
:rate="progress"
|
:rate="progress"
|
||||||
@ -65,14 +72,17 @@ import { Circle as VanCircle } from 'vant';
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import tips2Url from '@/assets/step2/2.mp4?url';
|
import tips2Url from '@/assets/step2/2.mp4?url';
|
||||||
|
import faceSvgUrl from '@/assets/step2/face.svg?url';
|
||||||
|
|
||||||
const PREVIEW_ID = 'step2-preview';
|
const PREVIEW_ID = 'step2-preview';
|
||||||
|
const tipsText = ref('');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const statusText = ref('');
|
const statusText = ref('');
|
||||||
const progress = ref(0);
|
const progress = ref(0);
|
||||||
const currentRate = ref(0);
|
const currentRate = ref(0);
|
||||||
let started = false;
|
let started = false;
|
||||||
|
const isRecording = ref(false);
|
||||||
|
const cameraReady = ref(false);
|
||||||
let released = false;
|
let released = false;
|
||||||
let removeResizeListener: (() => void) | null = null;
|
let removeResizeListener: (() => void) | null = null;
|
||||||
const PREVIEW_ASPECT = 4 / 3; // 4:3,不裁剪;想更宽可改 16/9
|
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 RETICLE_CENTER_Y = 0.3;
|
||||||
|
|
||||||
const tipsVideoEl = ref<HTMLVideoElement | null>(null);
|
const tipsVideoEl = ref<HTMLVideoElement | null>(null);
|
||||||
// 相机未授权/未启动时不要播放提示视频
|
// 提示视频始终显示,但不自动播放(由用户点击触发播放)
|
||||||
const showTipsVideo = ref(false);
|
const showTipsVideo = ref(true);
|
||||||
const tipsVideoSrc = computed(() => tips2Url);
|
const tipsVideoSrc = computed(() => tips2Url);
|
||||||
let navigatingToStep3 = false;
|
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() {
|
function onTipsTimeUpdate() {
|
||||||
const el = tipsVideoEl.value;
|
const el = tipsVideoEl.value;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@ -99,6 +119,7 @@ function tryPlayTips() {
|
|||||||
el.play().catch(() => {
|
el.play().catch(() => {
|
||||||
// iOS/部分机型可能需要用户手势;这里允许用户点一下视频开始播放
|
// iOS/部分机型可能需要用户手势;这里允许用户点一下视频开始播放
|
||||||
});
|
});
|
||||||
|
changeTipsText([{text: '请正视镜头', time: 0}, {text: '请向右转头', time: 2000}, {text: '请向左转头', time: 4000}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTipsEnded() {
|
function onTipsEnded() {
|
||||||
@ -142,6 +163,7 @@ async function videoUrlToBlob(videoUrl: string): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setupNativeRecorder() {
|
async function setupNativeRecorder() {
|
||||||
|
cameraReady.value = false;
|
||||||
const vw = Math.round(window.innerWidth);
|
const vw = Math.round(window.innerWidth);
|
||||||
const vh = Math.round(window.innerHeight);
|
const vh = Math.round(window.innerHeight);
|
||||||
// 让预览按比例“contain”居中,避免满屏裁剪导致人脸变大
|
// 让预览按比例“contain”居中,避免满屏裁剪导致人脸变大
|
||||||
@ -176,6 +198,8 @@ async function setupNativeRecorder() {
|
|||||||
|
|
||||||
await CameraPreview.startRecordVideo({});
|
await CameraPreview.startRecordVideo({});
|
||||||
started = true;
|
started = true;
|
||||||
|
isRecording.value = true;
|
||||||
|
cameraReady.value = true;
|
||||||
statusText.value = '前置摄像头录制中';
|
statusText.value = '前置摄像头录制中';
|
||||||
|
|
||||||
// 尺寸变化时同步更新原生预览,保持按比例居中
|
// 尺寸变化时同步更新原生预览,保持按比例居中
|
||||||
@ -213,6 +237,8 @@ async function teardownRecorder() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
released = true;
|
released = true;
|
||||||
|
isRecording.value = false;
|
||||||
|
cameraReady.value = false;
|
||||||
if (removeResizeListener) {
|
if (removeResizeListener) {
|
||||||
removeResizeListener();
|
removeResizeListener();
|
||||||
removeResizeListener = null;
|
removeResizeListener = null;
|
||||||
@ -247,7 +273,8 @@ onMounted(async () => {
|
|||||||
document.documentElement.classList.add('step2-camera-active');
|
document.documentElement.classList.add('step2-camera-active');
|
||||||
progress.value = 0;
|
progress.value = 0;
|
||||||
currentRate.value = 0;
|
currentRate.value = 0;
|
||||||
showTipsVideo.value = false;
|
showTipsVideo.value = true;
|
||||||
|
cameraReady.value = false;
|
||||||
|
|
||||||
if (!Capacitor.isNativePlatform()) {
|
if (!Capacitor.isNativePlatform()) {
|
||||||
statusText.value = '请在真机或模拟器(Capacitor)中打开以使用前置录像';
|
statusText.value = '请在真机或模拟器(Capacitor)中打开以使用前置录像';
|
||||||
@ -268,14 +295,15 @@ onMounted(async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await setupNativeRecorder();
|
await setupNativeRecorder();
|
||||||
// 相机正常启动后再显示/播放提示视频
|
// 相机启动成功后自动播放提示视频
|
||||||
showTipsVideo.value = true;
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
tryPlayTips();
|
tryPlayTips();
|
||||||
|
//
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
statusText.value = '无法启动相机或麦克风,请检查系统权限';
|
statusText.value = '无法启动相机或麦克风,请检查系统权限';
|
||||||
showTipsVideo.value = false;
|
showTipsVideo.value = true;
|
||||||
|
cameraReady.value = false;
|
||||||
const el = tipsVideoEl.value;
|
const el = tipsVideoEl.value;
|
||||||
if (el) {
|
if (el) {
|
||||||
try {
|
try {
|
||||||
@ -330,7 +358,7 @@ onBeforeUnmount(async () => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.85);
|
||||||
/* 中间圆形镂空(iOS/WKWebView 友好) */
|
/* 中间圆形镂空(iOS/WKWebView 友好) */
|
||||||
-webkit-mask: radial-gradient(
|
-webkit-mask: radial-gradient(
|
||||||
circle at 50% 30%,
|
circle at 50% 30%,
|
||||||
@ -351,17 +379,9 @@ onBeforeUnmount(async () => {
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 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{
|
img{
|
||||||
width: 34px;
|
width: 100%;
|
||||||
height: 34px;
|
height: 100%;
|
||||||
object-fit: contain;
|
|
||||||
filter: invert(1);
|
|
||||||
opacity: 0.95;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -415,6 +435,88 @@ onBeforeUnmount(async () => {
|
|||||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
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 {
|
.bottom {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -427,6 +529,11 @@ onBeforeUnmount(async () => {
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: 0 28px 42px;
|
padding: 0 28px 42px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
.tips-text{
|
||||||
|
color: #fff;
|
||||||
|
font-size: 40px;
|
||||||
|
margin-bottom: 82px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom {
|
.zoom {
|
||||||
@ -467,7 +574,7 @@ onBeforeUnmount(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
margin: 0 auto;
|
margin: 148px auto 0;
|
||||||
width: 605px;
|
width: 605px;
|
||||||
height: 82px;
|
height: 82px;
|
||||||
img{
|
img{
|
||||||
|
|||||||
@ -5,10 +5,19 @@
|
|||||||
<div class="title">
|
<div class="title">
|
||||||
<img src="@/assets/step1/title.png" alt="">
|
<img src="@/assets/step1/title.png" alt="">
|
||||||
</div>
|
</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>
|
||||||
<div class="main">
|
<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">
|
<div class="tips">
|
||||||
<img class="tips-icon" src="@/assets/step3/loading.png" alt="">正在分析你的身体状态…
|
<img class="tips-icon" src="@/assets/step3/loading.png" alt="">正在分析你的身体状态…
|
||||||
</div>
|
</div>
|
||||||
@ -19,7 +28,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core';
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const showHelp = ref(true);
|
const showHelp = ref(true);
|
||||||
@ -35,25 +44,82 @@ const ARK_API_KEY =
|
|||||||
(import.meta.env.VITE_ARK_API_KEY as string | undefined) ||
|
(import.meta.env.VITE_ARK_API_KEY as string | undefined) ||
|
||||||
'3496e327-0454-426c-8e69-13e905a1e756';
|
'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 src = Capacitor.convertFileSrc(videoUrl.trim());
|
||||||
const res = await fetch(src);
|
const res = await fetch(src, signal ? { signal } : undefined);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`读取本地视频失败: ${res.status}`);
|
throw new Error(`读取本地视频失败: ${res.status}`);
|
||||||
}
|
}
|
||||||
return res.blob();
|
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) => {
|
return await new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onerror = () => reject(new Error('读取视频失败'));
|
let settled = false;
|
||||||
reader.onload = () => resolve(String(reader.result || ''));
|
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);
|
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');
|
if (!ARK_API_KEY) throw new Error('未设置 VITE_ARK_API_KEY');
|
||||||
|
|
||||||
const requestBody: any = {
|
const requestBody: any = {
|
||||||
@ -65,79 +131,69 @@ async function analyzeVideoWithArk(videoDataUrl: string) {
|
|||||||
{ type: 'input_video', video_url: videoDataUrl },
|
{ type: 'input_video', video_url: videoDataUrl },
|
||||||
{
|
{
|
||||||
type: 'input_text',
|
type: 'input_text',
|
||||||
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)“正常”。请点击查看详细报告,看看您本轮的检测结果吧~" } }`
|
||||||
你是一位基于计算机视觉的医疗级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": "检测显示您的生理机能处于极佳状态,皮肤状况良好,心理压力较低,整体呈现出阳光自信的状态。"
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(ARK_API_URL, {
|
return await new Promise<any>((resolve, reject) => {
|
||||||
method: 'POST',
|
const xhr = new XMLHttpRequest();
|
||||||
headers: {
|
let settled = false;
|
||||||
Authorization: `Bearer ${ARK_API_KEY}`,
|
const cleanup = () => {
|
||||||
'Content-Type': 'application/json'
|
if (signal) signal.removeEventListener('abort', onAbort);
|
||||||
},
|
};
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
|
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 () => {
|
onMounted(async () => {
|
||||||
@ -149,6 +205,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
cancelInFlightRequest();
|
||||||
|
const controller = new AbortController();
|
||||||
|
pipelineAbortController.value = controller;
|
||||||
|
const { signal } = controller;
|
||||||
|
|
||||||
const now =
|
const now =
|
||||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
? () => performance.now()
|
? () => performance.now()
|
||||||
@ -156,16 +217,19 @@ onMounted(async () => {
|
|||||||
const t0 = now();
|
const t0 = now();
|
||||||
|
|
||||||
const tBlob0 = now();
|
const tBlob0 = now();
|
||||||
const blob = await videoUrlToBlob(videoPath);
|
const blob = await videoUrlToBlob(videoPath, signal);
|
||||||
const tBlob1 = now();
|
const tBlob1 = now();
|
||||||
|
|
||||||
const tDataUrl0 = now();
|
const tDataUrl0 = now();
|
||||||
const dataUrl = await blobToDataUrl(blob);
|
if (signal.aborted) throw createAbortError();
|
||||||
|
const dataUrl = await blobToDataUrl(blob, signal);
|
||||||
const tDataUrl1 = now();
|
const tDataUrl1 = now();
|
||||||
|
|
||||||
const tArk0 = now();
|
const tArk0 = now();
|
||||||
const result = await analyzeVideoWithArk(dataUrl);
|
if (signal.aborted) throw createAbortError();
|
||||||
|
const result = await analyzeVideoWithArk(dataUrl, signal);
|
||||||
const tArk1 = now();
|
const tArk1 = now();
|
||||||
|
if (isLeaving.value) return;
|
||||||
|
|
||||||
sessionStorage.setItem('step2_ark_result', JSON.stringify(result));
|
sessionStorage.setItem('step2_ark_result', JSON.stringify(result));
|
||||||
const tSave = now();
|
const tSave = now();
|
||||||
@ -183,6 +247,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
router.push('/step4');
|
router.push('/step4');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (isLeaving.value) return;
|
||||||
|
if (e?.name === 'AbortError') return;
|
||||||
console.error(e);
|
console.error(e);
|
||||||
errorMessage.value = e?.message || '识别失败';
|
errorMessage.value = e?.message || '识别失败';
|
||||||
router.push('/step1');
|
router.push('/step1');
|
||||||
@ -192,6 +258,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelInFlightRequest();
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.step3 {
|
.step3 {
|
||||||
|
|||||||
@ -136,6 +136,9 @@ const list3 = ref<any[]>([]);
|
|||||||
const arkResult = sessionStorage.getItem('step2_ark_result');
|
const arkResult = sessionStorage.getItem('step2_ark_result');
|
||||||
if (arkResult) {
|
if (arkResult) {
|
||||||
const result = JSON.parse(arkResult);
|
const result = JSON.parse(arkResult);
|
||||||
|
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
// summaryText.value = result?.output[0]?.summary[0]?.text;
|
// summaryText.value = result?.output[0]?.summary[0]?.text;
|
||||||
// console.log(summaryText.value); //文本
|
// console.log(summaryText.value); //文本
|
||||||
data.value = JSON.parse(result?.output[1]?.content[0]?.text);
|
data.value = JSON.parse(result?.output[1]?.content[0]?.text);
|
||||||
|
|||||||