详情数据对接完整
@ -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"
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BIN
src/assets/images/avatar.png
Normal file
|
After Width: | Height: | Size: 525 KiB |
BIN
src/assets/images/card-bg.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
src/assets/images/chart.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
src/assets/images/content2-tips.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
src/assets/images/content3-tips.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
src/assets/images/content4-icon1.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src/assets/images/content4-tips1.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
src/assets/images/content4-tips2.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
src/assets/images/content5-line.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
src/assets/images/content5-progress.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src/assets/images/content5-status1.png
Normal file
|
After Width: | Height: | Size: 901 B |
BIN
src/assets/images/content5-status2.png
Normal file
|
After Width: | Height: | Size: 896 B |
BIN
src/assets/images/content5-status3.png
Normal file
|
After Width: | Height: | Size: 691 B |
BIN
src/assets/images/content5-tips.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
src/assets/images/dot.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/images/icon1.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/images/icon2.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src/assets/images/icon3.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/images/icon4.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src/assets/images/indicator-bg.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/images/legend.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
src/assets/images/status1.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src/assets/images/status2.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src/assets/images/status3.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src/assets/step1/1.jpg
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/assets/step1/1_.mp4
Normal file
BIN
src/assets/step2/2.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/step3/3.jpg
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
41
src/components/Item/index.vue
Normal 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>
|
||||||
BIN
src/components/Tips/icon.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
32
src/components/Tips/index.vue
Normal 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>
|
||||||
@ -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({
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
1516
src/views/step4.vue
653
src/views/step4bf.vue
Normal 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: '120–139 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* forward:detail 从下往上进,report 往上出 */
|
||||||
|
.step4-slide-up-enter-from {
|
||||||
|
opacity: 0.01;
|
||||||
|
transform: translateY(100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step4-slide-up-leave-to {
|
||||||
|
opacity: 0.01;
|
||||||
|
transform: translateY(-100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* back:report 从上往下进,detail 往下出(反向) */
|
||||||
|
.step4-slide-down-enter-from {
|
||||||
|
opacity: 0.01;
|
||||||
|
transform: translateY(-100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step4-slide-down-leave-to {
|
||||||
|
opacity: 0.01;
|
||||||
|
transform: translateY(100vh);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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>
|
|
||||||
|
|
||||||