详情数据对接完整

This commit is contained in:
mzhang93 2026-05-04 00:17:44 +08:00
parent 20e34edd87
commit 57eb948d37
46 changed files with 2204 additions and 638 deletions

View File

@ -35,6 +35,9 @@ dependencies {
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.media3:media3-transformer:1.10.0"
implementation "androidx.media3:media3-effect:1.10.0"
implementation "androidx.media3:media3-common:1.10.0"
implementation project(':capacitor-android') implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"

View File

@ -7,6 +7,7 @@ import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
registerPlugin(VideoCompressPlugin.class);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
// Allow autoplay media without a user gesture (Android WebView). // Allow autoplay media without a user gesture (Android WebView).

View File

@ -0,0 +1,196 @@
package io.ionic.starter;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.media.MediaMetadataRetriever;
import androidx.annotation.NonNull;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.File;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.transformer.DefaultEncoderFactory;
import androidx.media3.transformer.EditedMediaItem;
import androidx.media3.transformer.ExportException;
import androidx.media3.transformer.ExportResult;
import androidx.media3.transformer.Transformer;
import androidx.media3.transformer.VideoEncoderSettings;
@CapacitorPlugin(name = "VideoCompress")
public class VideoCompressPlugin extends Plugin {
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private static long tryGetDurationMs(Context ctx, Uri uri) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
retriever.setDataSource(ctx, uri);
String dur = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
if (dur == null) return -1;
return Long.parseLong(dur);
} catch (Throwable ignored) {
return -1;
} finally {
try {
retriever.release();
} catch (Throwable ignored) {
/* ignore */
}
}
}
private static long tryGetFileSize(Uri uri) {
try {
if ("file".equals(uri.getScheme())) {
File f = new File(uri.getPath() == null ? "" : uri.getPath());
return f.exists() ? f.length() : -1;
}
// content:// 体积用 outFile.length 就足够比较输入大小无法保证可取到
return -1;
} catch (Throwable ignored) {
return -1;
}
}
private static Uri coerceToUri(String input) {
if (input == null) return null;
String s = input.trim();
if (s.isEmpty()) return null;
if (s.startsWith("file://") || s.startsWith("content://")) {
return Uri.parse(s);
}
// Assume absolute file path.
return Uri.fromFile(new File(s));
}
@PluginMethod
@UnstableApi
public void compressTo4s(PluginCall call) {
try {
final String inputPath = call.getString("path", "");
// <= 0 means keep full duration (no clipping).
final int durationMs = call.getInt("durationMs", 0);
final long videoBitrate = call.getLong("videoBitrate", 600_000L);
final boolean removeAudio = call.getBoolean("removeAudio", true);
final Uri inputUri = coerceToUri(inputPath);
if (inputUri == null) {
call.reject("path 不能为空");
return;
}
// Transformer 必须在同一条应用线程上创建与访问统一放到主线程执行
mainHandler.post(() -> {
try {
Context ctx = getContext();
final long inputDurationMs = tryGetDurationMs(ctx, inputUri);
final long inputFileSize = tryGetFileSize(inputUri);
File outDir = new File(ctx.getCacheDir(), "video_exports");
//noinspection ResultOfMethodCallIgnored
outDir.mkdirs();
String suffix = durationMs > 0 ? ("_" + durationMs + "ms") : "_full";
File outFile = new File(outDir, "step2_" + System.currentTimeMillis() + suffix + ".mp4");
String outputPath = outFile.getAbsolutePath();
MediaItem.Builder mediaItemBuilder = new MediaItem.Builder().setUri(inputUri);
if (durationMs > 0) {
MediaItem.ClippingConfiguration clipping =
new MediaItem.ClippingConfiguration.Builder()
.setStartPositionMs(0)
.setEndPositionMs(durationMs)
.build();
mediaItemBuilder.setClippingConfiguration(clipping);
}
MediaItem mediaItem = mediaItemBuilder.build();
EditedMediaItem edited =
new EditedMediaItem.Builder(mediaItem)
.setRemoveAudio(removeAudio)
.build();
int videoBitrateInt;
if (videoBitrate > Integer.MAX_VALUE) {
videoBitrateInt = Integer.MAX_VALUE;
} else if (videoBitrate < 1) {
videoBitrateInt = 1;
} else {
videoBitrateInt = (int) videoBitrate;
}
VideoEncoderSettings videoEncoderSettings =
new VideoEncoderSettings.Builder()
.setBitrate(videoBitrateInt)
.build();
DefaultEncoderFactory encoderFactory =
new DefaultEncoderFactory.Builder(ctx)
.setRequestedVideoEncoderSettings(videoEncoderSettings)
.setEnableFallback(true)
.build();
Transformer.Listener listener =
new Transformer.Listener() {
@Override
public void onCompleted(@NonNull androidx.media3.transformer.Composition composition, @NonNull ExportResult result) {
long outputDurationMs = tryGetDurationMs(ctx, Uri.fromFile(outFile));
JSObject ret = new JSObject();
ret.put("outputPath", outputPath);
ret.put("outputFileUri", "file://" + outputPath);
ret.put("fileSize", outFile.length());
JSObject inputInfo = new JSObject();
inputInfo.put("durationMs", inputDurationMs);
inputInfo.put("fileSize", inputFileSize);
inputInfo.put("uri", String.valueOf(inputUri));
JSObject outputInfo = new JSObject();
outputInfo.put("durationMs", outputDurationMs);
outputInfo.put("fileSize", outFile.length());
outputInfo.put("uri", "file://" + outputPath);
ret.put("input", inputInfo);
ret.put("output", outputInfo);
call.resolve(ret);
}
@Override
public void onError(
@NonNull androidx.media3.transformer.Composition composition,
@NonNull ExportResult result,
@NonNull ExportException exception) {
call.reject("视频压缩失败: " + exception.getMessage(), exception);
}
};
Transformer transformer =
new Transformer.Builder(ctx)
.setEncoderFactory(encoderFactory)
.setVideoMimeType(MimeTypes.VIDEO_H264)
.setAudioMimeType(MimeTypes.AUDIO_AAC)
.experimentalSetTrimOptimizationEnabled(true)
.addListener(listener)
.build();
transformer.start(edited, outputPath);
} catch (Exception e) {
call.reject("启动压缩失败: " + e.getMessage(), e);
}
});
} catch (Exception e) {
call.reject("参数错误: " + e.getMessage(), e);
}
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
src/assets/images/chart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
src/assets/images/dot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
src/assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/assets/images/icon1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
src/assets/images/icon2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
src/assets/images/icon3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
src/assets/images/icon4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
src/assets/step1/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

BIN
src/assets/step1/1_.mp4 Normal file

Binary file not shown.

BIN
src/assets/step2/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

View File

@ -0,0 +1,41 @@
<template>
<div class="item">
<div class="title">{{ title }}</div>
<slot></slot>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
title: string
}>();
</script>
<style scoped lang="scss">
.item{
margin: 0 auto;
border-radius: 37px;
border: 2px solid #fff;
padding: 20px;
box-sizing: border-box;
width: 956px;
border:1px solid #fff;
background: #fff;
padding: 50px;
.title{
font-size: 30px;
font-weight: 900;
color: #000;
position: relative;
&::before{
content: '';
position: absolute;
width: 8px;
height: 28px;
background: url('@/assets/images/icon.png') no-repeat left top / cover;
left: -18px;
top: 50%;
transform: translateY(-50%);
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -0,0 +1,32 @@
<template>
<div class="tips">
<div class="title">{{ title }}</div>
<div class="text">{{ text }}</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
title: string
text: string
}>();
</script>
<style scoped lang="scss">
.tips{
padding: 27px 50px;
background:rgba(226, 206, 137, 0.12);
border-radius: 18px;
.title{
padding-left: 52px;
font-size: 30px;
// font-weight: 550;
color: #000;
background: url('./icon.png') no-repeat left center / 40px 40px;
line-height: 42px;
}
.text{
margin-top: 14px;
font-size: 30px;
line-height: 48px;
}
}
</style>

View File

@ -22,10 +22,7 @@ const routes: Array< RouteRecordRaw > = [
path: '/step4', path: '/step4',
component: () => import('@/views/step4.vue') component: () => import('@/views/step4.vue')
}, },
{
path: '/step5',
component: () => import('@/views/step5.vue')
}
] ]
const router = createRouter({ const router = createRouter({

View File

@ -8,7 +8,32 @@
<div class="main-top"> <div class="main-top">
<div class="ip" ref="ipRef"> <div class="ip" ref="ipRef">
<!-- muted --> <!-- muted -->
<video class="ip-video" :src="introVideoSrc" autoplay playsinline webkit-playsinline preload="auto" /> <div class="ip-videoWrap">
<video
v-show="!showLoopVideo"
ref="introVideoEl"
class="ip-video"
poster="@/assets/step1/1.jpg"
:src="introVideoSrc"
autoplay
playsinline
webkit-playsinline
preload="auto"
@ended="onIntroEnded"
/>
<video
v-show="showLoopVideo"
ref="loopVideoEl"
class="ip-video"
:src="loopVideoSrc"
autoplay
muted
playsinline
webkit-playsinline
preload="auto"
loop
/>
</div>
</div> </div>
<div class="r"> <div class="r">
<div class="video-tips"> <div class="video-tips">
@ -26,14 +51,62 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { nextTick, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import introVideoUrl from '@/assets/step1/1.mp4?url'; import introVideoUrl from '@/assets/step1/1.mp4?url';
import introVideoUrl_ from '@/assets/step1/1_.mp4?url';
const showHelp = ref(true); const showHelp = ref(true);
const introVideoSrc = introVideoUrl;
const router = useRouter(); const router = useRouter();
const introVideoEl = ref<HTMLVideoElement | null>(null);
const loopVideoEl = ref<HTMLVideoElement | null>(null);
const introVideoSrc = ref<string>(introVideoUrl);
const loopVideoSrc = ref<string>(introVideoUrl_);
const showLoopVideo = ref(false);
async function ensureLoopPreloaded() {
const el = loopVideoEl.value;
if (!el) return;
try {
// /
el.load();
} catch {
/* ignore */
}
}
async function switchToLoopVideo() {
showLoopVideo.value = true;
await nextTick();
const el = loopVideoEl.value;
if (!el) return;
try {
el.currentTime = 0;
} catch {
/* ignore */
}
try {
await el.play();
} catch {
/* ignore */
}
}
function onIntroEnded() {
void switchToLoopVideo();
}
onMounted(() => {
// 1.mp4 1_.mp4
showLoopVideo.value = false;
introVideoSrc.value = introVideoUrl;
loopVideoSrc.value = introVideoUrl_;
void ensureLoopPreloaded();
});
const start = () => { const start = () => {
router.push('/step2'); router.push('/step2');
} }
@ -45,7 +118,8 @@ const start = () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
background: url('@/assets/step1/bg.png') no-repeat center center/cover; // background: url('@/assets/step1/bg.png') no-repeat center center/cover;
background: #F3F3F3;
min-height: 100vh; min-height: 100vh;
.top { .top {
@ -84,6 +158,16 @@ const start = () => {
/* 与 step4 的 main 同布局:居中的 ip 容器 + 左右绝对定位元素 */ /* 与 step4 的 main 同布局:居中的 ip 容器 + 左右绝对定位元素 */
.ip { .ip {
.ip-videoWrap {
width: 100%;
height: 100%;
position: relative;
}
.ip-video {
position: absolute;
inset: 0;
}
width: 485.04px; width: 485.04px;
height: 1309px; height: 1309px;

View File

@ -5,14 +5,17 @@
<!-- 叠加层圆形取景框 + 关闭按钮 + 进度环 --> <!-- 叠加层圆形取景框 + 关闭按钮 + 进度环 -->
<div class="camera-overlay"> <div class="camera-overlay">
<div class="back" @click="router.replace('/')"><img src="@/assets/step2/back.png" alt=""></div> <div class="back" @click="onBackClick"><img src="@/assets/step2/back.png" alt=""></div>
<div class="tipsvideo" v-if="showTipsVideo"> <div class="tipsvideo" >
<video <video
ref="tipsVideoEl" ref="tipsVideoEl"
:src="tipsVideoSrc" :src="tipsVideoSrc"
:poster="tipsVideoPosterSrc"
playsinline playsinline
preload="auto" preload="auto"
@loadeddata="tipsVideoReady = true"
@error="tipsVideoReady = false"
@ended="onTipsEnded" @ended="onTipsEnded"
@timeupdate="onTipsTimeUpdate" @timeupdate="onTipsTimeUpdate"
@click="tryPlayTips" @click="tryPlayTips"
@ -72,6 +75,7 @@ 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 tips2PosterUrl from '@/assets/step2/2.jpg?url';
import faceSvgUrl from '@/assets/step2/face.svg?url'; import faceSvgUrl from '@/assets/step2/face.svg?url';
const PREVIEW_ID = 'step2-preview'; const PREVIEW_ID = 'step2-preview';
@ -91,8 +95,10 @@ const RETICLE_CENTER_Y = 0.3;
const tipsVideoEl = ref<HTMLVideoElement | null>(null); const tipsVideoEl = ref<HTMLVideoElement | null>(null);
// //
const showTipsVideo = ref(true);
const tipsVideoReady = ref(false);
const tipsVideoSrc = computed(() => tips2Url); const tipsVideoSrc = computed(() => tips2Url);
const tipsVideoPosterSrc = computed(() => tips2PosterUrl);
let navigatingToStep3 = false; let navigatingToStep3 = false;
const changeTipsText = (arr: {text: string, time: number}[]) => { const changeTipsText = (arr: {text: string, time: number}[]) => {
@ -119,7 +125,7 @@ function tryPlayTips() {
el.play().catch(() => { el.play().catch(() => {
// iOS/ // iOS/
}); });
changeTipsText([{text: '请正视镜头', time: 0}, {text: '请向右转头', time: 2000}, {text: '请向左转头', time: 4000}]); changeTipsText([{text: '请正视镜头', time: 0}, {text: '请向右转头', time: 6000}, {text: '请向左转头', time: 10000}]);
} }
function onTipsEnded() { function onTipsEnded() {
@ -143,7 +149,7 @@ function onTipsEnded() {
// setTimeout(() => { // setTimeout(() => {
queueMicrotask(async () => { queueMicrotask(async () => {
try { try {
await teardownRecorder(); // sessionStorage['step2_video_path'] await teardownRecorder(); // sessionStorage['step2_video_path'] step3
} finally { } finally {
router.push('/step3'); router.push('/step3');
} }
@ -184,7 +190,7 @@ async function setupNativeRecorder() {
toBack: true, toBack: true,
position: 'front', position: 'front',
enableVideoMode: true, enableVideoMode: true,
disableAudio: false, disableAudio: true,
// / // /
aspectMode: 'contain', aspectMode: 'contain',
width: pw, width: pw,
@ -268,12 +274,20 @@ async function back() {
router.go(-1); router.go(-1);
} }
async function onBackClick() {
try {
await teardownRecorder();
} finally {
router.replace('/');
}
}
onMounted(async () => { onMounted(async () => {
released = false; released = false;
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 = true;
cameraReady.value = false; cameraReady.value = false;
if (!Capacitor.isNativePlatform()) { if (!Capacitor.isNativePlatform()) {
@ -302,7 +316,7 @@ onMounted(async () => {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
statusText.value = '无法启动相机或麦克风,请检查系统权限'; statusText.value = '无法启动相机或麦克风,请检查系统权限';
showTipsVideo.value = true;
cameraReady.value = false; cameraReady.value = false;
const el = tipsVideoEl.value; const el = tipsVideoEl.value;
if (el) { if (el) {
@ -317,7 +331,6 @@ onMounted(async () => {
}); });
onBeforeUnmount(async () => { onBeforeUnmount(async () => {
await teardownRecorder();
document.documentElement.classList.remove('step2-camera-active'); document.documentElement.classList.remove('step2-camera-active');
try { try {
await StatusBar.setOverlaysWebView({ overlay: true }); await StatusBar.setOverlaysWebView({ overlay: true });

View File

@ -6,20 +6,14 @@
<img src="@/assets/step1/title.png" alt=""> <img src="@/assets/step1/title.png" alt="">
</div> </div>
<div class="r" @click="onCloseClick"> <div class="r" @click="onCloseClick">
<img src="@/assets/close.png" alt=""></div> <img src="@/assets/close.png" alt="">
</div>
</div> </div>
<div class="main"> <div class="main">
<video <video class="close-video" :src="closeVideoSrc" :poster="closeVideoPosterSrc" playsinline autoplay loop
class="close-video" preload="auto" @loadeddata="closeVideoReady = true" @error="closeVideoReady = false" />
:src="closeVideoSrc"
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="">{{ tipsText }}
</div> </div>
</div> </div>
@ -28,16 +22,33 @@
<script setup lang="ts"> <script setup lang="ts">
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { registerPlugin } from '@capacitor/core';
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { CameraPreview } from '@capgo/camera-preview';
import closeVideoUrl from '@/assets/step3/3.mp4?url'; import closeVideoUrl from '@/assets/step3/3.mp4?url';
import closePosterUrl from '@/assets/step3/3.jpg?url';
const tipsText = ref('正在分析您的皮肤状态...');
const showHelp = ref(true); const showHelp = ref(true);
const router = useRouter(); const router = useRouter();
const closeVideoSrc = closeVideoUrl; const closeVideoSrc = closeVideoUrl;
const start = () => { const closeVideoPosterSrc = closePosterUrl;
router.push('/step2'); 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 errorMessage = ref('');
const ARK_API_URL = const ARK_API_URL =
(import.meta.env.VITE_ARK_API_URL as string | undefined) || (import.meta.env.VITE_ARK_API_URL as string | undefined) ||
@ -133,7 +144,87 @@ async function analyzeVideoWithArk(videoDataUrl: string, signal?: AbortSignal) {
{ type: 'input_video', video_url: videoDataUrl }, { type: 'input_video', video_url: videoDataUrl },
{ {
type: 'input_text', type: 'input_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)“正常”。请点击查看详细报告,看看您本轮的检测结果吧~" } }` text: `角色设定
你是一位基于多模态感知计算机视觉 + 音频分析的医疗级AI健康顾问你的核心能力是通过分析面部微细血管的颜色变化rPPG技术原理皮肤纹理细节衣着穿搭风格以及语音语调的情感特征来推断用户的生理与心理健康数据
核心原则
真实世界的亚健康模拟
拒绝完美数据严禁生成所有指标都完美的教科书式数据
倾向轻微异常真实用户通常处于亚健康状态请倾向于生成轻度偏高/偏低的数据熬夜导致的心率稍快压力导致的皮肤出油饮食不规律导致的血糖波动避免生成严重的病理数据除非视觉特征极度明显
基于证据的生理推断
面色潮红/充血 -> 对应心率/血压轻度偏高压力饮酒
面色苍白/无血色 -> 对应血红蛋白偏低疲劳低血糖
皮肤暗沉/油光重 -> 对应代谢负担皮质醇高压力缺水
关怀式反馈机制
模块级分析如果指标异常analysis 字段必须解释视觉成因并给出具体改善建议
全局总结summary_text 必须用温暖关怀的口吻串联所有异常点并针对每个问题给出生活化的解决方案
分析步骤
视觉与听觉特征提取观察面部rPPG信号皮肤纹理衣着风格分析语音的语速音调颤抖
数值生成与状态判定
构思数值模拟真实波动允许轻度偏离标准值
比对范围确定 status ("正常""偏高""偏低")
建议生成针对每一个异常指标构思一条具体的行动建议多喝水深呼吸补铁等
报告撰写输出 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%)
输出格式
请只返回 JSON不要 Markdown/不要多余文字格式如下
{
"visual_quality_check": {
"lighting": "good",
"face_clarity": "high",
"signal_reliability": "valid"
},
"metrics": {
"vital_signs": {
"heart_rate": { "value": 92, "unit": "bpm", "status": "偏高", "desc": "心率" },
"respiratory_rate": { "value": 16, "unit": "rpm", "status": "正常", "desc": "呼吸频率" },
"systolic_bp": { "value": 142, "unit": "mmHg", "status": "偏高", "desc": "收缩压" },
"diastolic_bp": { "value": 85, "unit": "mmHg", "status": "正常", "desc": "舒张压" },
"analysis": "检测到面部微血管轻微充血心率与收缩压轻度偏高。这可能与刚才的说话状态或近期咖啡因摄入有关。建议尝试进行3分钟的深呼吸练习帮助心率恢复平稳。"
},
"blood_health": {
"glucose": { "value": 5.4, "unit": "mmol/L", "status": "正常", "desc": "血糖" },
"hemoglobin": { "value": 108, "unit": "g/L", "status": "偏低", "desc": "血红蛋白" },
"triglycerides": { "value": 1.7, "unit": "mmol/L", "status": "偏高", "desc": "甘油三酯" },
"analysis": "唇色稍显苍白,提示血红蛋白轻度偏低,可能存在轻微贫血风险;甘油三酯略高可能与近期饮食油腻有关。建议:适当摄入红肉或菠菜补充铁质,并减少晚餐的油脂摄入。"
},
"skin_status": {
"skin_age": { "value": 27, "unit": "years", "status": "正常", "desc": "皮肤年龄" },
"skin_type": { "value": "混合性", "unit": "", "status": "正常", "desc": "肤质类型" },
"hydration": { "value": 11, "unit": "%", "status": "偏低", "desc": "皮肤含水量" },
"dark_circles": { "value": "中度", "unit": "", "status": "正常", "desc": "黑眼圈状态" },
"acne": { "value": "少量", "unit": "", "status": "正常", "desc": "痤疮" },
"oil_control": { "value": "中度", "unit": "", "status": "正常", "desc": "出油状态" },
"analysis": "皮肤含水量偏低且伴有中度黑眼圈这是典型的“熬夜肌”特征皮肤屏障处于缺水状态。建议今晚尝试在11点前入睡并使用保湿力度更强的面霜。"
},
"mental_health": {
"mental_score": { "value": 72.0, "unit": "score", "status": "正常", "desc": "心理健康指数" },
"stress": { "value": 7.2, "unit": "score", "status": "偏高", "desc": "压力指数" },
"depression": { "value": 2.0, "unit": "score", "status": "正常", "desc": "抑郁指数" },
"anxiety": { "value": 6.5, "unit": "score", "status": "偏高", "desc": "焦虑指数" },
"analysis": "语音分析显示语速较快且句尾音调有轻微上扬紧张特征判断压力与焦虑指数偏高。您可能正处于项目攻坚期。建议工作间隙进行5分钟冥想或听一些轻音乐来放松神经。"
}
},
"brief_report": {
"personality": "进取型/易焦虑",
"emotion": "紧张",
"clothing_style": "商务休闲",
"overall_status": "需关注",
"abnormal_items": [
"心率偏高",
"收缩压偏高",
"血红蛋白偏低",
"甘油三酯偏高",
"皮肤含水量偏低",
"压力指数偏高",
"焦虑指数偏高"
],
"summary_text": "亲爱的用户根据本次检测结合您的商务休闲着装与略显急促的语调我感受到您可能是一位正在为工作全力以赴的“进取型”伙伴。您的身体状况整体尚可但身体正在发出一些“求关注”的信号1. 心血管与情绪您的心率、血压及压力指数均偏高这说明您现在可能有些紧绷。建议放下手机做几次深呼吸给自己一点喘息的空间。2. 营养与代谢血红蛋白与甘油三酯的轻微波动提示饮食可能不够规律。建议多吃深色蔬菜少吃油腻外卖。3. 皮肤状态:缺水和黑眼圈提示您该好好睡一觉了。建议:今晚早点休息,让身体自我修复。请点击查看详细报告,让我们一起调整状态,找回活力!"
}
}`
}, },
] ]
} }
@ -199,14 +290,61 @@ async function analyzeVideoWithArk(videoDataUrl: string, signal?: AbortSignal) {
} }
onMounted(async () => { onMounted(async () => {
// step2
if (Capacitor.isNativePlatform()) {
try {
await CameraPreview.stop({ force: true });
} catch {
/* ignore */
}
}
// step2 sessionStorage['step2_video_path'] // step2 sessionStorage['step2_video_path']
const videoPath = sessionStorage.getItem('step2_video_path') || ''; let videoPath = sessionStorage.getItem('step2_video_path') || '';
if (!videoPath) { if (!videoPath) {
// router.push('/step4'); // router.push('/step4');
return; return;
} }
try { 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(); cancelInFlightRequest();
const controller = new AbortController(); const controller = new AbortController();
pipelineAbortController.value = controller; pipelineAbortController.value = controller;
@ -272,7 +410,8 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
background: url('@/assets/step1/bg.png') no-repeat center center/cover; // background: url('@/assets/step1/bg.png') no-repeat center center/cover;
background: #F3F3F3;
min-height: 100vh; min-height: 100vh;
.top { .top {
@ -326,7 +465,8 @@ onUnmounted(() => {
.tips { .tips {
display: flex; display: flex;
width: 473px; // width: 473px;
text-align: center;
height: 67px; height: 67px;
margin: 100px auto 0; margin: 100px auto 0;
font-size: 32px; font-size: 32px;

File diff suppressed because it is too large Load Diff

653
src/views/step4bf.vue Normal file
View File

@ -0,0 +1,653 @@
<template>
<div class="step4-shell">
<Transition :name="transitionName">
<!-- 报告页 step4 -->
<div v-if="view === 'report'" key="report" class="step4 step4--report">
<div class="top">
<div class="l"></div>
<div class="title">
我的检测报告
</div>
<div class="r" @click="router.replace('/')"><img src="@/assets/close.png" alt=""></div>
</div>
<div class="time"> 检测时间<span>{{ creatTime }}</span></div>
<div class="main">
<div class="ip">
<div class="ip-top" @click="toStep5()">
<div class="top-l">
<div class="value">{{ data?.metrics?.skin_status?.skin_age?.value }}<span></span></div>
<div class="label">皮肤年龄</div>
</div>
<div class="line"></div>
<div class="top-r">
<div class="value">{{ data?.metrics?.mental_health?.mental_score?.value }}<span></span></div>
<div class="label">心理健康指数</div>
</div>
</div>
<div class="l" @click="toStep5()">
<ReportMetricCard v-for="(item, index) in list1" :key="index" v-bind="item" />
</div>
<div class="r" @click="toStep5()">
<ReportMetricCard v-for="(item, index) in list2" :key="index" v-bind="item" />
</div>
<img src="@/assets/step1/ip.png" alt="">
</div>
</div>
<div class="bottom" @click="toStep5()">
<ReportMetricCard v-for="(item, index) in list3" :key="index" v-bind="item" />
</div>
</div>
<!-- 详情页 step5 -->
<div v-else key="detail" class="step5 step4--detail">
<div class="top">
<div class="l" @click="backToReport()"> <img src="@/assets/back.png" alt=""></div>
<div class="title">
心理压力指数MSI指标
</div>
<div class="r" @click="router.replace('/')"><img src="@/assets/close.png" alt=""></div>
</div>
<div v-html="summaryText" class="summary-text"> </div>
<!-- <div class="main">
<img class="tips" src="@/assets/step5/tips.png" alt="">
<img class="ip" src="@/assets/step1/ip.png" alt="">
</div> -->
<!-- <div class="info">
<div class="title">以下是你的心理压力指数MSI指标</div>
<div class="msi-card">
<div class="msi-score">
<span class="value">{{ msiScore.toFixed(1) }}</span>
<span class="total">/{{ msiMax.toFixed(1) }}</span>
</div>
<div class="msi-gauge">
<div class="track" />
<div class="pointer" :style="{ left: msiPointerLeft }" />
</div>
<div class="msi-labels">
<span>1-2</span>
<span>2-3</span>
<span>3-4</span>
<span>4-5</span>
<span>5-6</span>
</div>
</div>
<div class="text">
<p> 结合总分与各维度得分测评对象当前压力状态呈现以下核心特征</p>
<p> <span>1. 情绪与行为层面问题突出</span>情绪易怒性回避行为两个维度得分偏高是当前压力的主要表现形式易导致工作效率下降人际沟通不畅等问题</p>
<p> <span>2. 身心基础能力偏弱</span>幸福感与适应能力得分偏低反映出个体身心能量不足对工作环境变化的适应力较弱难以快速缓冲压力带来的负面影响</p>
<p>
<span>3.社会支持存在提升空间</span>社会支持维度处于中等水平外部支持未被充分利用主动求助联结支持系统的行为不足进一步加剧了压力应对的难度基于测评结果结合职场场景与个体需求制定以下分层干预建议可根据实际情况逐步落实
每日开展 10-15 分钟情绪调节练习如正念呼吸渐进式肌肉放松或轻量冥想每日固定时段完成逐步提升情绪自控力
建立 情绪记录清单当出现烦躁焦虑情绪时及时记录情绪触发点与自身感受分析情绪来源针对性调整思维方式减少冲动反应
</p>
<p> 适度参与户外活动如散步慢跑瑜伽每周 2-3 每次 30 分钟以上通过运动释放负面情绪提升身心愉悦感</p>
</div>
</div> -->
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
//@ts-nocheck
import ReportMetricCard from '@/components/ReportMetricCard/index.vue';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { format } from 'silly-datetime';
const router = useRouter();
const view = ref<'report' | 'detail'>('report');
const direction = ref<'forward' | 'back'>('forward');
const transitionName = ref('step4-slide-up');
const summaryText = ref('');
const creatTime = ref<Date>(new Date());
const data = ref<any>({});
// MSI
const msiScore = ref(3.1);
const msiMax = ref(6.0);
const msiPointerLeft = computed(() => {
const ratio = Math.min(Math.max(msiScore.value / msiMax.value, 0), 1);
return `${ratio * 100}%`;
});
const toStep5 = () => {
direction.value = 'forward';
transitionName.value = 'step4-slide-up';
view.value = 'detail';
}
const backToReport = () => {
direction.value = 'back';
transitionName.value = 'step4-slide-down';
view.value = 'report';
};
const list1 = ref<any[]>([]);
const list2 = ref<any[]>([]);
const list3 = ref<any[]>([]);
/** 底部四个指标:`type` 决定布局,`iconType` 对应 icons/1~11.png */
//
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);
creatTime.value = format(new Date(result.created_at ? result.created_at * 1000 : new Date()), 'YYYY-MM-DD HH:mm:ss')
summaryText.value = data.value?.brief_report?.summary_text;
list1.value = [
{
type: 4 as const,
iconType: 1 as const,
title: '心率',
mainValue: data.value?.metrics?.vital_signs?.heart_rate?.value,
unit: 'bpm次/分钟)',
}, {
type: 4 as const,
iconType: 2 as const,
title: '呼吸频率',
mainValue: data.value?.metrics?.vital_signs?.respiratory_rate?.value,
unit: '12-20次/分',
},
{
type: 4 as const,
iconType: 3 as const,
title: '舒张压',
mainValue: data.value?.metrics?.vital_signs?.diastolic_bp?.value,
unit: '85~89 mmHg',
},
{
type: 4 as const,
iconType: 4 as const,
title: '收缩压',
mainValue: data.value?.metrics?.vital_signs?.systolic_bp?.value,
unit: '120139 mmHg',
},
];
list2.value = [
{
type: 4 as const,
iconType: 5 as const,
title: '血糖',
mainValue: data.value?.metrics?.blood_health?.glucose?.value,
unit: '≥7.0 mmol/L',
}, {
type: 4 as const,
iconType: 6 as const,
title: '血红蛋白',
mainValue: data.value?.metrics?.blood_health?.hemoglobin?.value,
unit: 'bmp次/分钟)',
},
{
type: 4 as const,
iconType: 7 as const,
title: '甘油三酯',
mainValue: data.value?.metrics?.blood_health?.triglycerides?.value,
unit: '12-15 mmol/L',
},
];
list3.value = [
// {
// type: 1 as const,
// title: '',
// score: 3.1,
// max: 6,
// },
{
type: 8 as const,
iconType: 1 as const,
title: '心理检测指数',
mainValue: data.value?.metrics?.mental_health?.mental_score?.value,
},
// {
// type: 3 as const,
// iconType: 8 as const,
// title: '',
// highlightText: '',
// highlightColor: '#fb7185',
// },
{
type: 2 as const,
iconType: 9 as const,
title: '压力指数',
mainValue: data.value?.metrics?.mental_health?.stress?.value,
},
{
type: 2 as const,
iconType: 10 as const,
title: '抑郁指数',
mainValue: data.value?.metrics?.mental_health?.depression?.value,
},
{
type: 2 as const,
iconType: 11 as const,
title: '焦虑指数',
mainValue: data.value?.metrics?.mental_health?.anxiety?.value,
},
];
}
// data.value ={"visual_quality_check": {"lighting": "good", "face_clarity": "high", "signal_reliability": "valid"}, "metrics": {"vital_signs": {"heart_rate": {"value": 76, "unit": "bpm", "status": "normal", "desc": ""}, "respiratory_rate": {"value": 15, "unit": "rpm", "status": "normal", "desc": ""}, "systolic_bp": {"value": 122, "unit": "mmHg", "status": "normal", "desc": ""}, "diastolic_bp": {"value": 80, "unit": "mmHg", "status": "normal", "desc": ""}}, "blood_health": {"glucose": {"value": 5.2, "unit": "mmol/L", "status": "normal", "desc": ""}, "hemoglobin": {"value": 132, "unit": "g/L", "status": "normal", "desc": ""}, "triglycerides": {"value": 1.1, "unit": "mmol/L", "status": "normal", "desc": ""}}, "skin_status": {"skin_age": {"value": 28, "unit": "years", "status": "normal", "desc": ""}}, "mental_health": {"mental_score": {"value": 82, "unit": "score", "status": "normal", "desc": ""}, "stress": {"value": 3, "unit": "score", "status": "normal", "desc": ""}, "depression": {"value": 2, "unit": "score", "status": "normal", "desc": ""}, "anxiety": {"value": 2, "unit": "score", "status": "normal", "desc": ""}}}, "brief_report": {"personality": "", "emotion": "", "overall_status": "", "abnormal_items": [], "summary_text": ""}}
</script>
<style scoped lang="scss">
.step4-shell {
position: relative;
height: 100vh;
overflow: hidden;
/* 过渡时底层兜底背景,避免透明时露出白屏 */
// background: url('@/assets/step1/bg.png') no-repeat center center/cover;
background: #F3F3F3;
}
.step4,
.step5 {
min-height: 100vh;
// background: url('@/assets/step1/bg.png') no-repeat center center/cover ;
background: #F3F3F3;
}
.step4 {
padding-bottom: 166px;
position: relative;
padding-top: 52px;
display: flex;
flex-direction: column;
// justify-content: space-between;
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;
}
}
.time {
line-height: 33px;
margin-top: 3px;
text-align: center;
font-size: 24px;
color: #000;
span {
color: #797979;
}
}
.main {
flex: 1;
position: relative;
.ip {
position: absolute;
top: 258px;
left: 50%;
transform: translate(-50%, 0);
width: 485.04px;
height: 1309px;
img {
width: 100%;
height: 100%;
}
.ip-top {
border-radius: 24px;
position: absolute;
top: -187px;
left: 50%;
transform: translateX(-50%);
width: 335px;
height: 105px;
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
.top-l,
.top-r {
flex: 1;
text-align: center;
.value {
font-size: 50px;
span {
font-size: 18px;
}
}
.label {
font-size: 18px;
}
}
.line {
width: 2px;
background: #D8D8D8;
height: 60%;
}
}
.l {
position: absolute;
width: 168px;
top: 125px;
left: -222px;
display: flex;
flex-wrap: wrap;
gap: 17px;
}
.r {
position: absolute;
width: 168px;
top: 125px;
right: -222px;
display: flex;
flex-wrap: wrap;
gap: 17px;
}
}
}
.bottom {
z-index: 2;
position: absolute;
bottom: 500px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 17px;
padding: 0 24px;
box-sizing: border-box;
}
}
/* 详情页(原 step5的样式直接复用 */
.step5 {
padding-top: 52px;
display: flex;
flex-direction: column;
// justify-content: space-between;
.top {
position: relative;
z-index: 2;
padding: 0 30px;
display: flex;
justify-content: space-between;
align-items: center;
.l {
width: 60px;
height: 60px;
}
.r {
width: 60px;
height: 60px;
img {
width: 100%;
}
}
.title {
height: 60px;
margin: 0 auto;
font-size: 50px;
}
}
.main {
transform: translateY(-200px);
flex: 1;
flex-direction: column;
display: flex;
justify-content: center;
align-items: center;
.ip {
margin-top: 61px;
width: 485.04px;
height: 1309px;
img {
width: 100%;
}
}
.tips {
display: block;
width: 404px;
height: 33px;
margin: 100px auto 0;
}
}
.info {
left: 50%;
transform: translateX(-50%);
border-radius: 20px;
position: absolute;
bottom: 43px;
width: 956px;
height: 888px;
background: rgba(255, 255, 255, 0.95);
padding: 24px 26px;
overflow-y: scroll;
&::-webkit-scrollbar {
display: none;
}
.title {
font-size: 30px;
font-weight: 650;
}
.msi-card {
margin-top: 22px;
width: 452px;
height: 202px;
background: #fff;
border-radius: 24px;
padding: 34px 34px 28px;
box-sizing: border-box;
}
.msi-score {
display: flex;
align-items: baseline;
justify-content: center;
gap: 10px;
.value {
font-size: 63px;
font-weight: 800;
color: #111827;
line-height: 1;
}
.total {
font-size: 43px;
font-weight: 600;
color: rgba(17, 24, 39, 0.45);
line-height: 1;
}
}
.msi-gauge {
position: relative;
margin-top: 26px;
padding-top: 18px;
}
.track {
height: 18px;
border-radius: 999px;
background: linear-gradient(90deg,
#22c55e 0%,
#eab308 50%,
#ff7a00 78%,
#ff3b30 100%);
}
.pointer {
position: absolute;
top: 0;
width: 40px;
height: 28px;
transform: translateX(-50%);
background: url('@/components/ReportMetricCard/icons/dot.png') no-repeat center center / contain;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.18));
}
.msi-labels {
margin-top: 18px;
display: flex;
justify-content: space-between;
font-size: 32px;
line-height: 1;
font-weight: 500;
span:nth-child(1),
span:nth-child(2) {
color: #22c55e;
}
span:nth-child(3) {
color: #eab308;
}
span:nth-child(4) {
color: #ff7a00;
}
span:nth-child(5) {
color: #ff3b30;
}
}
.text {
margin-top: 25px;
font-size: 30px;
color: #000;
line-height: 48px;
p {
span {
font-weight: 700;
}
}
}
}
.summary-text{
padding: 50px;
}
}
/* 内部切换动画:上下滑 + 淡入淡出(支持正/反向) */
.step4-slide-up-enter-active,
.step4-slide-down-enter-active {
transition: opacity 1100ms cubic-bezier(0.2, 0.9, 0.2, 1),
transform 1100ms cubic-bezier(0.2, 0.9, 0.2, 1);
will-change: opacity, transform;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 2;
// background: url('@/assets/step1/bg.png') no-repeat center center/cover;
background: #F3F3F3;
}
.step4-slide-up-leave-active,
.step4-slide-down-leave-active {
transition: opacity 1100ms cubic-bezier(0.2, 0.9, 0.2, 1),
transform 1100ms cubic-bezier(0.2, 0.9, 0.2, 1);
will-change: opacity, transform;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
/* 叠层时也保持背景,避免透明叠加时出现白边/白屏 */
// background: url('@/assets/step1/bg.png') no-repeat center center/cover;
background: #F3F3F3;
}
.step4-slide-up-enter-to,
.step4-slide-up-leave-from,
.step4-slide-down-enter-to,
.step4-slide-down-leave-from {
opacity: 1;
}
/* forwarddetail 从下往上进report 往上出 */
.step4-slide-up-enter-from {
opacity: 0.01;
transform: translateY(100vh);
}
.step4-slide-up-leave-to {
opacity: 0.01;
transform: translateY(-100vh);
}
/* backreport 从上往下进detail 往下出(反向) */
.step4-slide-down-enter-from {
opacity: 0.01;
transform: translateY(-100vh);
}
.step4-slide-down-leave-to {
opacity: 0.01;
transform: translateY(100vh);
}
</style>

View File

@ -1,86 +0,0 @@
<template>
<div class="step5">
<div class="top">
<div class="l"> <img src="@/assets/back.png" alt=""></div>
<div class="title">
心理压力指数MSI指标
</div>
<div class="r"><img src="@/assets/close.png" alt=""></div>
</div>
<div class="main">
<img class="ip" src="@/assets/step3/main.png" alt="">
<img class="tips" src="@/assets/step3/tips.png" alt="">
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const showHelp = ref(true);
const router = useRouter();
const start = () => {
router.push('/step2');
}
</script>
<style scoped lang="scss">
.step5 {
padding-top: 52px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: url('@/assets/step1/bg.png') no-repeat center center/cover;
min-height: 100vh;
.top {
padding: 0 30px;
display: flex;
justify-content: space-between;
align-items: center;
.l {
width: 60px;
height: 60px;
}
.r {
width: 60px;
height: 60px;
img {
width: 100%;
}
}
.title {
height: 60px;
margin: 0 auto;
font-size: 50px;
img {
width: 100%;
}
}
}
.main {
transform: translateY(-200px);
flex: 1;
flex-direction: column;
display: flex;
justify-content: center;
align-items: center;
.ip {
width: 100%;
}
.tips {
display: block;
width: 473px;
height: 67px;
margin: 100px auto 0;
}
}
}
</style>