Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49efa21af3 | ||
|
|
c8507aed43 | ||
|
|
87b8949f95 | ||
|
|
1626b6f498 | ||
|
|
68b5b12b59 | ||
|
|
9e684f0afb | ||
|
|
8a6890767e | ||
|
|
d57e60539e | ||
|
|
78fd00fa05 | ||
|
|
fd7e6071a8 | ||
|
|
7192c4db8c | ||
|
|
b02c340348 | ||
|
|
0f21dd4451 | ||
|
|
1ed3e06fdf | ||
|
|
c608145023 | ||
|
|
4f95d9182e | ||
|
|
f662723a25 | ||
|
|
32370df7fe | ||
|
|
62467dc01c | ||
|
|
97fefbae8f | ||
|
|
541fe59ad1 | ||
|
|
b6c5b422c2 | ||
|
|
500038601c | ||
|
|
f6688a740d | ||
|
|
a347e0642c | ||
|
|
13adbac3fd | ||
|
|
333a18c37e | ||
|
|
e3a91a91f7 | ||
|
|
5ca5f06255 | ||
|
|
e01f3e7072 | ||
|
|
ed78803015 | ||
|
|
546138d60e | ||
|
|
fdddfdb3dd | ||
|
|
f5a89b4310 | ||
|
|
a650747fd3 | ||
|
|
46b62229a6 | ||
|
|
0f33f2ad28 | ||
|
|
a677b69e97 | ||
|
|
6432e9990c | ||
|
|
4d6125268a | ||
|
|
7f93cf5480 | ||
|
|
f8f1953a2a | ||
|
|
99eca8b2e5 | ||
|
|
bde396189a | ||
|
|
55e3b5d9e0 | ||
|
|
eb0efba739 | ||
|
|
c7e7022181 | ||
|
|
26e6cc433b | ||
|
|
c22b84cff9 | ||
|
|
4f9486fb3d | ||
|
|
d24b04f9e9 | ||
|
|
465cba3788 | ||
|
|
dc590d37e9 | ||
|
|
d1f587fdd7 | ||
|
|
e8f68101c4 | ||
|
|
cea9392239 | ||
|
|
3ea3c97c42 | ||
|
|
efd058bce5 | ||
|
|
e538dd03a1 | ||
|
|
4a17fe8a0c | ||
|
|
e2a1e62867 | ||
|
|
b6cdd20c56 | ||
|
|
c1be0aa3e5 | ||
|
|
6c87b72ce4 | ||
|
|
e28480af32 | ||
|
|
37b2945c44 | ||
|
|
c9c2b4608f | ||
|
|
e61e7f97fa | ||
|
|
14e5bba9a2 | ||
|
|
6b713a2d6c | ||
|
|
8ae5b4eead | ||
|
|
e5959fd89a | ||
|
|
08249451a7 | ||
|
|
79ae055bb6 | ||
|
|
c0cc23393e | ||
|
|
470ba419e3 | ||
|
|
1c0cd84355 | ||
|
|
f2e2f84e18 | ||
|
|
f7e52bcafc | ||
|
|
2e59e883b8 | ||
|
|
9f1801771e | ||
|
|
1adf8ccf4b | ||
|
|
abc78f4291 | ||
|
|
072c0c3663 | ||
|
|
e9cf65a41c | ||
|
|
af9bd05bb4 | ||
|
|
18f7fb3772 | ||
|
|
29ab45033d | ||
|
|
8a1a9ea0ca | ||
|
|
f8ccd8b0af | ||
|
|
9e019708f2 | ||
|
|
2712c262bd | ||
|
|
bd7002e176 | ||
|
|
0be6d06544 | ||
|
|
7a4973470c | ||
|
|
e79f3190b8 | ||
|
|
1811abc794 | ||
|
|
07abf2abca | ||
|
|
c76bd5416a | ||
|
|
7bb8db4126 | ||
|
|
30b6037752 | ||
|
|
5a235280a1 | ||
|
|
b9d36d8d04 | ||
|
|
f37310ec50 | ||
|
|
a440702c07 | ||
|
|
b9486f32fe | ||
|
|
07018598ad | ||
|
|
c554e08f2e | ||
|
|
e6209c8912 | ||
|
|
e49f806c3a | ||
|
|
b566e60624 | ||
|
|
123e9075dd | ||
|
|
84eef45553 | ||
|
|
9873ac5e87 | ||
|
|
cdf8b1f76d | ||
|
|
b0b78c82ab | ||
|
|
ff61e815d8 | ||
|
|
dd93ffe683 | ||
|
|
ef014e0d9d | ||
|
|
32f8e19bf1 | ||
|
|
26b2cde699 | ||
|
|
5ab3be1fdd | ||
|
|
6b528c34c8 | ||
|
|
2138626519 | ||
|
|
37df208706 | ||
|
|
ec4e0b9f29 | ||
|
|
a3bae1555a | ||
|
|
15e9c486f9 | ||
|
|
eadc82535d | ||
|
|
62f74888f3 | ||
|
|
79719b801e | ||
|
|
07dacbcb28 | ||
|
|
d364fe506a | ||
|
|
860b550180 | ||
|
|
707e4888ad | ||
|
|
b0a8bd83bc | ||
|
|
574d37908a | ||
|
|
62512d30f9 | ||
|
|
1dd5133202 | ||
|
|
51b34a98b5 | ||
|
|
1755185f50 | ||
|
|
b3d3048100 | ||
|
|
a38af5bb13 | ||
|
|
450be5852f | ||
|
|
3c35ab67cd | ||
|
|
7cfa4d269a |
@@ -26,11 +26,8 @@ bin-release/
|
|||||||
/.cursor
|
/.cursor
|
||||||
/.codex
|
/.codex
|
||||||
/.agents
|
/.agents
|
||||||
/.claude
|
|
||||||
/.claude
|
|
||||||
/.claude
|
|
||||||
/.kbctx
|
|
||||||
/server/.spool
|
/server/.spool
|
||||||
/server/.claude
|
/server/.claude
|
||||||
/.spool
|
/.spool
|
||||||
TUICallKit-Vue3/.env
|
TUICallKit-Vue3/.env
|
||||||
|
/.codegraph
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
alias ll='ls -alhG'
|
|
||||||
|
|
||||||
# Starship prompt
|
|
||||||
eval "$(starship init zsh)"
|
|
||||||
|
|
||||||
# zsh-autosuggestions 自动补全
|
|
||||||
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
|
|
||||||
|
|
||||||
# NVM 懒加载 - 只在第一次使用相关命令时再初始化
|
|
||||||
export NVM_DIR="$HOME/.nvm"
|
|
||||||
_nvm_default_bin() {
|
|
||||||
local default_alias version_dir
|
|
||||||
[ -r "$NVM_DIR/alias/default" ] || return 1
|
|
||||||
|
|
||||||
default_alias="$(<"$NVM_DIR/alias/default")"
|
|
||||||
default_alias="${default_alias#"${default_alias%%[![:space:]]*}"}"
|
|
||||||
default_alias="${default_alias%"${default_alias##*[![:space:]]}"}"
|
|
||||||
|
|
||||||
case "$default_alias" in
|
|
||||||
v[0-9]*)
|
|
||||||
version_dir="$NVM_DIR/versions/node/$default_alias"
|
|
||||||
;;
|
|
||||||
[0-9]*)
|
|
||||||
version_dir="$(ls -d "$NVM_DIR"/versions/node/v"$default_alias".* 2>/dev/null | tail -n 1)"
|
|
||||||
;;
|
|
||||||
node|stable|lts/*)
|
|
||||||
version_dir="$(ls -d "$NVM_DIR"/versions/node/v* 2>/dev/null | sort -V | tail -n 1)"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[ -n "$version_dir" ] && [ -x "$version_dir/bin/node" ] || return 1
|
|
||||||
printf '%s\n' "$version_dir/bin"
|
|
||||||
}
|
|
||||||
if _nvm_default_node_bin="$(_nvm_default_bin)"; then
|
|
||||||
export PATH="$_nvm_default_node_bin:$PATH"
|
|
||||||
fi
|
|
||||||
unset _nvm_default_node_bin
|
|
||||||
_lazy_load_nvm() {
|
|
||||||
unset -f nvm node npm npx pnpm yarn 2>/dev/null
|
|
||||||
[ -s "$NVM_DIR/nvm.sh" ] || return 1
|
|
||||||
\. "$NVM_DIR/nvm.sh"
|
|
||||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
|
||||||
hash -r 2>/dev/null
|
|
||||||
}
|
|
||||||
nvm() { _lazy_load_nvm && nvm "$@"; }
|
|
||||||
node() { _lazy_load_nvm && node "$@"; }
|
|
||||||
npm() { _lazy_load_nvm && npm "$@"; }
|
|
||||||
npx() { _lazy_load_nvm && npx "$@"; }
|
|
||||||
pnpm() { _lazy_load_nvm && pnpm "$@"; }
|
|
||||||
yarn() { _lazy_load_nvm && yarn "$@"; }
|
|
||||||
export PATH="/Applications/EServer/bin:$PATH"
|
|
||||||
function EC_start(){
|
|
||||||
/Applications/EasyConnect.app/Contents/Resources/bin/EasyMonitor > /dev/null 2>&1 &
|
|
||||||
/Applications/EasyConnect.app/Contents/MacOS/EasyConnect > /dev/null 2>&1 &
|
|
||||||
open /Applications/EasyConnect.app
|
|
||||||
}
|
|
||||||
|
|
||||||
function EC_kill(){
|
|
||||||
pkill EasyMonitor
|
|
||||||
pkill ECAgent
|
|
||||||
pkill ECAgentProxy
|
|
||||||
pkill EasyConnect
|
|
||||||
}
|
|
||||||
# The following lines have been added by Docker Desktop to enable Docker CLI completions.
|
|
||||||
fpath=(/Users/long/.docker/completions $fpath)
|
|
||||||
autoload -Uz compinit
|
|
||||||
compinit
|
|
||||||
# End of Docker CLI completions
|
|
||||||
|
|
||||||
|
|
||||||
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/
|
|
||||||
|
|
||||||
# pyenv 懒加载 - 只在第一次使用 Python 相关命令时再初始化
|
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
|
||||||
_lazy_load_pyenv() {
|
|
||||||
unset -f pyenv python python3 pip pip3 2>/dev/null
|
|
||||||
|
|
||||||
local pyenv_bin
|
|
||||||
pyenv_bin="$(command -v pyenv 2>/dev/null)"
|
|
||||||
if [ -z "$pyenv_bin" ] && [ -x /opt/homebrew/bin/pyenv ]; then
|
|
||||||
pyenv_bin=/opt/homebrew/bin/pyenv
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ -n "$pyenv_bin" ] || return 1
|
|
||||||
|
|
||||||
eval "$("$pyenv_bin" init --path)"
|
|
||||||
eval "$("$pyenv_bin" init - zsh)"
|
|
||||||
hash -r 2>/dev/null
|
|
||||||
}
|
|
||||||
pyenv() { _lazy_load_pyenv && pyenv "$@"; }
|
|
||||||
python() { _lazy_load_pyenv && python "$@"; }
|
|
||||||
python3() { _lazy_load_pyenv && python3 "$@"; }
|
|
||||||
pip() { _lazy_load_pyenv && pip "$@"; }
|
|
||||||
pip3() { _lazy_load_pyenv && pip3 "$@"; }
|
|
||||||
|
|
||||||
# 引入 bash 配置
|
|
||||||
if [ -f ~/.bash_profile ]; then
|
|
||||||
source ~/.bash_profile
|
|
||||||
fi
|
|
||||||
if [ -f ~/.bashrc ]; then
|
|
||||||
source ~/.bashrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Added by Windsurf
|
|
||||||
export PATH="/Users/long/.codeium/windsurf/bin:$PATH"
|
|
||||||
|
|
||||||
# Added by Antigravity
|
|
||||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
|
|
||||||
# MindOS Desktop — CLI (mindos)
|
|
||||||
export PATH="$HOME/.mindos/bin:$PATH"
|
|
||||||
|
|
||||||
source "$HOME/.cargo/env"
|
|
||||||
|
|
||||||
# Added by Antigravity
|
|
||||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
alias ll='ls -alhG'
|
|
||||||
|
|
||||||
# Starship prompt
|
|
||||||
eval "$(starship init zsh)"
|
|
||||||
|
|
||||||
# zsh-autosuggestions 自动补全
|
|
||||||
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
|
|
||||||
|
|
||||||
# NVM
|
|
||||||
export NVM_DIR="$HOME/.nvm"
|
|
||||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
|
||||||
\. "$NVM_DIR/nvm.sh"
|
|
||||||
fi
|
|
||||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
|
||||||
export PATH="/Applications/EServer/bin:$PATH"
|
|
||||||
function EC_start(){
|
|
||||||
/Applications/EasyConnect.app/Contents/Resources/bin/EasyMonitor > /dev/null 2>&1 &
|
|
||||||
/Applications/EasyConnect.app/Contents/MacOS/EasyConnect > /dev/null 2>&1 &
|
|
||||||
open /Applications/EasyConnect.app
|
|
||||||
}
|
|
||||||
|
|
||||||
function EC_kill(){
|
|
||||||
pkill EasyMonitor
|
|
||||||
pkill ECAgent
|
|
||||||
pkill ECAgentProxy
|
|
||||||
pkill EasyConnect
|
|
||||||
}
|
|
||||||
# The following lines have been added by Docker Desktop to enable Docker CLI completions.
|
|
||||||
fpath=(/Users/long/.docker/completions $fpath)
|
|
||||||
autoload -Uz compinit
|
|
||||||
compinit
|
|
||||||
# End of Docker CLI completions
|
|
||||||
|
|
||||||
|
|
||||||
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
|
||||||
export PATH="/opt/homebrew/bin:$PYENV_ROOT/bin:$PATH"
|
|
||||||
if command -v pyenv >/dev/null 2>&1; then
|
|
||||||
eval "$(pyenv init --path)"
|
|
||||||
eval "$(pyenv init - zsh)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 引入 bash 配置
|
|
||||||
if [ -f ~/.bash_profile ]; then
|
|
||||||
source ~/.bash_profile
|
|
||||||
fi
|
|
||||||
if [ -f ~/.bashrc ]; then
|
|
||||||
source ~/.bashrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Added by Windsurf
|
|
||||||
export PATH="/Users/long/.codeium/windsurf/bin:$PATH"
|
|
||||||
|
|
||||||
# Added by Antigravity
|
|
||||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
|
||||||
|
|
||||||
if command -v nvm >/dev/null 2>&1; then
|
|
||||||
_nvm_default_version="$(nvm version default 2>/dev/null)"
|
|
||||||
if [ -n "$_nvm_default_version" ] && [ -d "$NVM_DIR/versions/node/$_nvm_default_version/bin" ]; then
|
|
||||||
export PATH="$NVM_DIR/versions/node/$_nvm_default_version/bin:$PATH"
|
|
||||||
fi
|
|
||||||
unset _nvm_default_version
|
|
||||||
fi
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
|
|
||||||
# MindOS Desktop — CLI (mindos)
|
|
||||||
export PATH="$HOME/.mindos/bin:$PATH"
|
|
||||||
|
|
||||||
source "$HOME/.cargo/env"
|
|
||||||
|
|
||||||
# Added by Antigravity
|
|
||||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
|
||||||
@@ -22,3 +22,5 @@ unpackage/dist
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
TUICallKit
|
TUICallKit
|
||||||
|
# 本工程的通话源码复用仓库内 ../wx/TUICallKit;保留相对软链接以便干净克隆后可直接构建。
|
||||||
|
!TUICallKit
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||||
import { ref} from "vue";
|
import { ref} from "vue";
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
|
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
|
||||||
import { getCurrentInstance } from 'vue'
|
import { getCurrentInstance } from 'vue'
|
||||||
@@ -97,11 +96,7 @@ const video =async (data)=>{
|
|||||||
},
|
},
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
|
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
console.log('SDKAppID',SDKAppID)
|
console.log('SDKAppID',SDKAppID)
|
||||||
getApp().globalData.userID = userId;
|
getApp().globalData.userID = userId;
|
||||||
getApp().globalData.userSig = userSig;
|
getApp().globalData.userSig = userSig;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../wx/TUICallKit
|
||||||
@@ -239,7 +239,6 @@
|
|||||||
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
|
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
|
||||||
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
|
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
|
||||||
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
|
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
|
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
|
||||||
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
|
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
|
||||||
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
|
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
|
||||||
@@ -561,17 +560,12 @@ const { proxy } = getCurrentInstance()
|
|||||||
},
|
},
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
|
|
||||||
patient_data.value=res.data.data
|
patient_data.value=res.data.data
|
||||||
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
getApp().globalData.userID = userId;
|
getApp().globalData.userID = userId;
|
||||||
getApp().globalData.userSig = userSig;
|
getApp().globalData.userSig = userSig;
|
||||||
getApp().globalData.SDKAppID = SDKAppID;
|
getApp().globalData.SDKAppID = SDKAppID;
|
||||||
getApp().globalData.secretKey = secretKey;
|
|
||||||
uni.setStorageSync('CallManager', userId)
|
uni.setStorageSync('CallManager', userId)
|
||||||
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
|
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<view class="tab-dock">
|
||||||
|
<view class="tab-dock__inner">
|
||||||
|
<view class="tab-dock__item" @click="goHome">
|
||||||
|
<image
|
||||||
|
class="tab-dock__icon"
|
||||||
|
:src="active === 0 ? '/static/user/home_no.png' : '/static/user/home.png'"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text class="tab-dock__label" :style="{ color: active === 0 ? activeColor : color }">首页</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-dock__item tab-dock__item--center" @click="goWeekly">
|
||||||
|
<view class="tab-dock__fab" :class="{ 'tab-dock__fab--active': active === 1 }">
|
||||||
|
<text class="tab-dock__fab-text">糖</text>
|
||||||
|
</view>
|
||||||
|
<text class="tab-dock__label tab-dock__label--center" :style="{ color: active === 1 ? activeColor : color }">血糖</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-dock__item" @click="goUser">
|
||||||
|
<image
|
||||||
|
class="tab-dock__icon"
|
||||||
|
:src="active === 2 ? '/static/user/user_no.png' : '/static/user/user.png'"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text class="tab-dock__label" :style="{ color: active === 2 ? activeColor : color }">我的</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="tab-dock__safe" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
/** 0 首页 1 血糖 2 我的 */
|
||||||
|
active: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const color = '#8a8a8a'
|
||||||
|
const activeColor = '#204e2b'
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
if (props.active === 0) return
|
||||||
|
uni.redirectTo({ url: '/pages/index/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goUser() {
|
||||||
|
if (props.active === 2) return
|
||||||
|
uni.redirectTo({ url: '/pages/user/user' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goWeekly() {
|
||||||
|
if (props.active === 1) return
|
||||||
|
uni.redirectTo({ url: '/tongji/pages/weekly' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.tab-dock {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 999;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
|
||||||
|
box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__item {
|
||||||
|
flex: 1;
|
||||||
|
height: 100rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__item--center {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__icon {
|
||||||
|
width: 46rpx;
|
||||||
|
height: 46rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__label {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__label--center {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__fab {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
margin-top: -48rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);
|
||||||
|
border: 6rpx solid #ffffff;
|
||||||
|
box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__fab--active {
|
||||||
|
transform: scale(1.04);
|
||||||
|
box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__fab-text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dock__safe {
|
||||||
|
height: env(safe-area-inset-bottom);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,68 @@
|
|||||||
<template>
|
<template>
|
||||||
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
<!-- 页面内嵌模块(如 weekly 统计页主内容区) -->
|
||||||
|
<view
|
||||||
|
v-if="visible && props.inline"
|
||||||
|
class="dev-entry-inline"
|
||||||
|
:class="{ 'dev-entry-inline--weekly': props.tone === 'weekly' }"
|
||||||
|
>
|
||||||
|
<view class="inline-hero">
|
||||||
|
<view class="inline-hero-icon" aria-hidden="true">
|
||||||
|
<view class="inline-hero-wave" />
|
||||||
|
</view>
|
||||||
|
<view class="inline-hero-text">
|
||||||
|
<view class="inline-hero-title-row">
|
||||||
|
<text class="inline-title">居家耗糖训练</text>
|
||||||
|
<text v-if="props.tone === 'weekly'" class="inline-pill">体验</text>
|
||||||
|
</view>
|
||||||
|
<text class="inline-sub">低强度动起来,帮助身体消耗糖分</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
scroll-x
|
||||||
|
class="inline-scroll"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
:enable-flex="true"
|
||||||
|
>
|
||||||
|
<view class="inline-scroll-track">
|
||||||
|
<view
|
||||||
|
v-for="item in inlineItems"
|
||||||
|
:key="item.url"
|
||||||
|
class="inline-card"
|
||||||
|
@click="goto(item.url)"
|
||||||
|
>
|
||||||
|
<view v-if="item.featured" class="inline-card-badge">
|
||||||
|
<text>推荐</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-icon" :class="item.iconClass">
|
||||||
|
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
|
||||||
|
<view class="dumbbell-plate dumbbell-plate-left" />
|
||||||
|
<view class="dumbbell-bar" />
|
||||||
|
<view class="dumbbell-plate dumbbell-plate-right" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="item.icon === 'pedal'" class="foot-pedal-shape">
|
||||||
|
<view class="pedal-bar pedal-bar-left" />
|
||||||
|
<view class="pedal-bar pedal-bar-right" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="item.icon === 'pilates'" class="pilates-ring-shape" />
|
||||||
|
<view v-else-if="item.icon === 'grip'" class="grip-ring-shape" />
|
||||||
|
<view v-else-if="item.icon === 'metro'" class="menu-icon--metro-inner">
|
||||||
|
<view class="metro-bar metro-bar-1" />
|
||||||
|
<view class="metro-bar metro-bar-2" />
|
||||||
|
<view class="metro-bar metro-bar-3" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="inline-card-title">{{ item.title }}</text>
|
||||||
|
<text class="inline-card-sub">{{ item.sub }}</text>
|
||||||
|
<text class="inline-card-go" aria-hidden="true">进入</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<text v-if="props.tone === 'weekly'" class="inline-scroll-hint">左右滑动,选择适合您的训练</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
||||||
<!-- 展开后的菜单卡片(从下往上动画) -->
|
<!-- 展开后的菜单卡片(从下往上动画) -->
|
||||||
<view v-if="expanded" class="menu-list" @click.stop>
|
<view v-if="expanded" class="menu-list" @click.stop>
|
||||||
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
|
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
|
||||||
@@ -84,25 +147,47 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
type TrainingItem = {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
sub: string
|
||||||
|
icon: 'dumbbell' | 'pedal' | 'pilates' | 'grip' | 'metro'
|
||||||
|
iconClass: string
|
||||||
|
featured?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const trainingItems: TrainingItem[] = [
|
||||||
|
{ url: '/training/pages/dumbbell', title: '哑铃', sub: '力量塑形', icon: 'dumbbell', iconClass: 'menu-icon--dumbbell' },
|
||||||
|
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
|
||||||
|
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
|
||||||
|
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
|
||||||
|
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
|
||||||
|
]
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
/** 悬浮按钮距底部 rpx(仅非 inline) */
|
||||||
bottom?: number
|
bottom?: number
|
||||||
|
/** 内嵌到页面主内容区,非 fixed 悬浮 */
|
||||||
|
inline?: boolean
|
||||||
|
/** weekly 页:与统计页 Vital Mint 视觉一致 */
|
||||||
|
tone?: 'default' | 'weekly'
|
||||||
}>(),
|
}>(),
|
||||||
{ bottom: 200 },
|
{ bottom: 200, inline: false, tone: 'default' },
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDevMode = (): boolean => {
|
/** weekly 将健走节拍器置前并标推荐 */
|
||||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
const inlineItems = computed(() => {
|
||||||
try {
|
if (props.tone !== 'weekly') return trainingItems
|
||||||
return process.env.NODE_ENV !== 'production'
|
const metro = trainingItems.find((i) => i.icon === 'metro')
|
||||||
} catch (_) {
|
const rest = trainingItems.filter((i) => i.icon !== 'metro')
|
||||||
return false
|
return metro ? [metro, ...rest] : trainingItems
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const visible = ref(isDevMode())
|
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
|
||||||
|
const visible = ref(true)
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
@@ -449,4 +534,196 @@ $brand-light: #34d399;
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-left: 4rpx;
|
margin-left: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-icon--metro-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 内嵌模块(weekly 等)
|
||||||
|
* ============================================================ */
|
||||||
|
.dev-entry-inline {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-entry-inline--weekly {
|
||||||
|
padding: 32rpx 28rpx 24rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
background: var(--surface-container-lowest, #fff);
|
||||||
|
border: 1rpx solid rgba(0, 108, 73, 0.12);
|
||||||
|
box-shadow:
|
||||||
|
0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08),
|
||||||
|
0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero-icon {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero-wave {
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23006c49' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-pill {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #006c49;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-scroll {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-scroll-track {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 4rpx 2rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 212rpx;
|
||||||
|
min-height: 220rpx;
|
||||||
|
padding: 20rpx 18rpx 18rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: #f4f7f5;
|
||||||
|
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
background: #e8f3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12rpx;
|
||||||
|
right: 12rpx;
|
||||||
|
padding: 4rpx 10rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #006c49;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.25;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card-sub {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.35;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card-go {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #006c49;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-scroll-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.inline-card:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -178,7 +178,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { getCurrentInstance } from 'vue'
|
import { getCurrentInstance } from 'vue'
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
let userId = ref("");
|
let userId = ref("");
|
||||||
@@ -379,10 +378,11 @@ const loginHandler = async (patient_id) => {
|
|||||||
data: { patient_id: patient_id },
|
data: { patient_id: patient_id },
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const { userId: patientUserId } = res.data
|
const {
|
||||||
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
|
userId: patientUserId,
|
||||||
userID: patientUserId,
|
userSig: patientUserSig,
|
||||||
})
|
sdkAppId: patientSDKAppID,
|
||||||
|
} = res.data
|
||||||
console.log('获取签名成功:', patientUserSig)
|
console.log('获取签名成功:', patientUserSig)
|
||||||
|
|
||||||
// 更新ref值
|
// 更新ref值
|
||||||
|
|||||||
@@ -51,18 +51,19 @@
|
|||||||
/* 小程序特有相关 */
|
/* 小程序特有相关 */
|
||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||||
|
"lazyCodeLoading" : "requiredComponents",
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false
|
"urlCheck" : false,
|
||||||
|
"minified" : true
|
||||||
},
|
},
|
||||||
"optimization" : {
|
"optimization" : {
|
||||||
"subPackages" : true
|
"subPackages" : true
|
||||||
},
|
},
|
||||||
"usingComponents" : true,
|
"usingComponents" : true,
|
||||||
"requiredBackgroundModes" : ["audio"],
|
"requiredBackgroundModes" : [ "audio" ],
|
||||||
|
|
||||||
"plugins" : {
|
"plugins" : {
|
||||||
"WechatSI" : {
|
"WechatSI" : {
|
||||||
"version" : "0.3.5",
|
"version" : "0.3.9",
|
||||||
"provider" : "wx069ba97219f66d99"
|
"provider" : "wx069ba97219f66d99"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4142,9 +4142,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4161,9 +4158,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4180,9 +4174,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4199,9 +4190,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4433,9 +4421,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4456,9 +4441,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4479,9 +4461,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4502,9 +4481,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4525,9 +4501,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4548,9 +4521,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4729,9 +4699,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4745,9 +4712,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4761,9 +4725,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4777,9 +4738,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4793,9 +4751,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4809,9 +4764,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4825,9 +4777,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4841,9 +4790,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4857,9 +4803,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4873,9 +4816,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4889,9 +4829,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4905,9 +4842,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4921,9 +4855,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/index/index",
|
"path": "pages/index/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "甄养堂"
|
"navigationBarTitleText": "甄养堂",
|
||||||
|
"backgroundColor": "#F8F8F8",
|
||||||
|
"backgroundColorTop": "#F8F8F8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -24,14 +26,6 @@
|
|||||||
"navigationBarTitleText": "甄养堂"
|
"navigationBarTitleText": "甄养堂"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "TUICallKit/src/Components/TUICallKit",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "视频问诊",
|
|
||||||
"navigationBarHidden": true,
|
|
||||||
"navigationStyle": "custom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/order/monad/monad",
|
"path": "pages/order/monad/monad",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -43,7 +37,9 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/user/user",
|
"path": "pages/user/user",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "我的"
|
"navigationBarTitleText": "我的",
|
||||||
|
"backgroundColor": "#e8eaed",
|
||||||
|
"backgroundColorTop": "#e8eaed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,6 +68,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subPackages": [
|
"subPackages": [
|
||||||
|
{
|
||||||
|
"root": "TUICallKit/src/Components",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "TUICallKit",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "视频问诊",
|
||||||
|
"navigationBarHidden": true,
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"root": "TUIKit",
|
"root": "TUIKit",
|
||||||
"pages": [
|
"pages": [
|
||||||
@@ -170,23 +179,13 @@
|
|||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"usingPlugins": {
|
"usingPlugins": {
|
||||||
"WechatSI": {
|
"WechatSI": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.9",
|
||||||
"provider": "wx069ba97219f66d99"
|
"provider": "wx069ba97219f66d99"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/more",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "日常护理",
|
|
||||||
"navigationBarBackgroundColor": "#204e2b",
|
|
||||||
"navigationBarTextStyle": "white",
|
|
||||||
"backgroundColor": "#faf9f5",
|
|
||||||
"enablePullDownRefresh": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/weekly",
|
"path": "pages/weekly",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -197,33 +196,44 @@
|
|||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"usingPlugins": {
|
"usingPlugins": {
|
||||||
"WechatSI": {
|
"WechatSI": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.9",
|
||||||
"provider": "wx069ba97219f66d99"
|
"provider": "wx069ba97219f66d99"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/more",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "日常护理",
|
||||||
|
"backgroundColor": "#f4fbf4",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"mp-weixin": {
|
||||||
|
"usingPlugins": {
|
||||||
|
"WechatSI": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"provider": "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "endless-game/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "识糖小课堂",
|
||||||
|
"backgroundColor": "#eefbf4",
|
||||||
|
"disableScroll": false,
|
||||||
|
"enableShareAppMessage": true,
|
||||||
|
"enableShareTimeline": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tabBar": {
|
|
||||||
"color": "#8a8a8a",
|
|
||||||
"selectedColor": "#204e2b",
|
|
||||||
"borderStyle": "black",
|
|
||||||
"backgroundColor": "#ffffff",
|
|
||||||
"list": [{
|
|
||||||
"pagePath": "pages/index/index",
|
|
||||||
"iconPath": "/static/user/home.png",
|
|
||||||
"selectedIconPath": "/static/user/home_no.png",
|
|
||||||
"text": "首页"
|
|
||||||
},{
|
|
||||||
"pagePath": "pages/user/user",
|
|
||||||
"iconPath": "/static/user/user.png",
|
|
||||||
"selectedIconPath": "/static/user/user_no.png",
|
|
||||||
"text": "我的"
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"navigationBarTitleText": "甄养堂互联网医院",
|
"navigationBarTitleText": "甄养堂互联网医院",
|
||||||
|
|||||||
@@ -118,13 +118,15 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
|
<TabBarDock :active="0" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { getCurrentInstance } from 'vue'
|
import { getCurrentInstance } from 'vue'
|
||||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
|
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
const doctors = ref([])
|
const doctors = ref([])
|
||||||
@@ -263,7 +265,8 @@ $outline: #727970;
|
|||||||
.container {
|
.container {
|
||||||
background: $background;
|
background: $background;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24rpx 32rpx 120rpx;
|
padding: 24rpx 32rpx;
|
||||||
|
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||||
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,6 @@
|
|||||||
import { ref,onMounted,getCurrentInstance} from "vue";
|
import { ref,onMounted,getCurrentInstance} from "vue";
|
||||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
|
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
|
||||||
let userID = ref("4");
|
let userID = ref("4");
|
||||||
@@ -140,13 +139,9 @@ const loginHandler = async (patient_id) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
|
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
console.log('获取签名成功:',userSig);
|
console.log('获取签名成功:',userSig);
|
||||||
getApp().globalData.userID = userId.value;
|
getApp().globalData.userID = userId;
|
||||||
getApp().globalData.userSig = userSig;
|
getApp().globalData.userSig = userSig;
|
||||||
getApp().globalData.SDKAppID = SDKAppID;
|
getApp().globalData.SDKAppID = SDKAppID;
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, getCurrentInstance,onMounted } from "vue";
|
import { ref, getCurrentInstance,onMounted } from "vue";
|
||||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
let userId = ref("");
|
let userId = ref("");
|
||||||
@@ -126,10 +125,7 @@ const loginHandler = async (patient_id) => {
|
|||||||
data: { patient_id: patient_id },
|
data: { patient_id: patient_id },
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
console.log('获取签名成功:',userSig);
|
console.log('获取签名成功:',userSig);
|
||||||
|
|
||||||
getApp().globalData.userID = userId;
|
getApp().globalData.userID = userId;
|
||||||
|
|||||||
@@ -370,7 +370,6 @@
|
|||||||
import { ref,onMounted,getCurrentInstance } from "vue";
|
import { ref,onMounted,getCurrentInstance } from "vue";
|
||||||
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
|
||||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
@@ -667,25 +666,25 @@ const closePatientModal = async () => {
|
|||||||
const granted = await requestAVPermissionsInModal();
|
const granted = await requestAVPermissionsInModal();
|
||||||
if (!granted) return; // 未授权,停止跳转
|
if (!granted) return; // 未授权,停止跳转
|
||||||
const app = getApp();
|
const app = getApp();
|
||||||
// const userID = app.globalData.userID;
|
|
||||||
// const userSig = app.globalData.userSig;
|
|
||||||
// const SDKAppID = app.globalData.SDKAppID;
|
|
||||||
const doctor = 'doctor_' + doctorId.value;
|
const doctor = 'doctor_' + doctorId.value;
|
||||||
await confirmDiagnosis(diagnosisId);
|
await confirmDiagnosis(diagnosisId);
|
||||||
if(!diagnosisId.includes('patient')){
|
const signatureRes = await proxy.apiUrl({
|
||||||
diagnosisId='patient_'+diagnosisId
|
url: '/api/tcm/getPatientSignature',
|
||||||
}
|
method: 'GET',
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
data: { patient_id: diagnosisId },
|
||||||
userID:diagnosisId,
|
}, false);
|
||||||
});
|
const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
|
||||||
if (!doctorId.value) {
|
if (!doctorId.value) {
|
||||||
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
|
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(app.globalData, diagnosisId);
|
app.globalData.userID = userId;
|
||||||
|
app.globalData.userSig = userSig;
|
||||||
|
app.globalData.SDKAppID = SDKAppID;
|
||||||
|
console.log(app.globalData, userId);
|
||||||
|
|
||||||
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(diagnosisId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
|
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
|
||||||
|
|
||||||
showPatientModal.value = false;
|
showPatientModal.value = false;
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
@@ -707,11 +706,7 @@ const video =async (data)=>{
|
|||||||
},
|
},
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
|
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
getApp().globalData.userID = userId;
|
getApp().globalData.userID = userId;
|
||||||
getApp().globalData.userSig = userSig;
|
getApp().globalData.userSig = userSig;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mine-page">
|
<view class="mine-page">
|
||||||
<!-- 顶部用户信息卡片 -->
|
<!-- 顶部用户信息卡片 -->
|
||||||
<div class="user-card" @click="navigateTo('/pages/user/userinfo')">
|
<div class="user-card" @click="navigateTo('/pages/user/userinfo')">
|
||||||
<div class="user-header">
|
<div class="user-header">
|
||||||
@@ -90,16 +90,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DEV 开发悬浮入口(生产环境自动隐藏) -->
|
<!-- DEV 开发悬浮入口(生产环境自动隐藏) -->
|
||||||
<dev-training-entry />
|
<dev-training-entry :bottom="360" />
|
||||||
</div>
|
<TabBarDock :active="2" />
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
|
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
|
||||||
|
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'profileB',
|
name: 'profileB',
|
||||||
components: { DevTrainingEntry },
|
components: { DevTrainingEntry, TabBarDock },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
userInfo: {
|
userInfo: {
|
||||||
@@ -170,6 +172,7 @@ export default {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #e8eaed;
|
background: #e8eaed;
|
||||||
padding: 40rpx;
|
padding: 40rpx;
|
||||||
|
padding-bottom: calc(40rpx + 200rpx + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card {
|
.user-card {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 45 KiB |
@@ -8,12 +8,12 @@
|
|||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
@error="onImageError"
|
@error="onImageError"
|
||||||
/>
|
/>
|
||||||
<text
|
<!-- <text
|
||||||
v-else
|
v-else
|
||||||
class="tj-icon-fallback"
|
class="tj-icon-fallback"
|
||||||
:class="[`tj-icon-fallback--${size}`]"
|
:class="[`tj-icon-fallback--${size}`]"
|
||||||
:style="{ color }"
|
:style="{ color }"
|
||||||
>{{ fallbackGlyph }}</text>
|
>{{ fallbackGlyph }}</text> -->
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -66,7 +66,12 @@ const ICON_PATHS = {
|
|||||||
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
|
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
|
||||||
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||||
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
|
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
|
||||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>'
|
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||||
|
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
|
||||||
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
|
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
|
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
|
||||||
|
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 图片加载失败时的 emoji 回退 */
|
/** 图片加载失败时的 emoji 回退 */
|
||||||
@@ -106,7 +111,12 @@ const ICON_FALLBACK = {
|
|||||||
egg: '🍳',
|
egg: '🍳',
|
||||||
home: '🏠',
|
home: '🏠',
|
||||||
'plus-circle': '⊕',
|
'plus-circle': '⊕',
|
||||||
'chevron-right': '›'
|
'chevron-right': '›',
|
||||||
|
'chevron-left': '‹',
|
||||||
|
check: '✓',
|
||||||
|
settings: '⚙',
|
||||||
|
syringe: '💉',
|
||||||
|
zap: '⚡'
|
||||||
}
|
}
|
||||||
|
|
||||||
const strokeColor = computed(() => {
|
const strokeColor = computed(() => {
|
||||||
@@ -155,6 +165,10 @@ function onImageError() {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
|
||||||
|
.tj-icon--send {
|
||||||
|
transform: translate(-10%, 10%);
|
||||||
|
}
|
||||||
.tj-icon-fallback {
|
.tj-icon-fallback {
|
||||||
display: block;
|
display: block;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
|
||||||
|
<view class="vsm-body">
|
||||||
|
<view class="vsm-leaf" aria-hidden="true" />
|
||||||
|
<view class="vsm-face">
|
||||||
|
<view class="vsm-eye vsm-eye--l" />
|
||||||
|
<view class="vsm-eye vsm-eye--r" />
|
||||||
|
<view class="vsm-blush vsm-blush--l" />
|
||||||
|
<view class="vsm-blush vsm-blush--r" />
|
||||||
|
<view class="vsm-mouth" />
|
||||||
|
</view>
|
||||||
|
<view v-if="active" class="vsm-waves" aria-hidden="true">
|
||||||
|
<view class="vsm-wave vsm-wave--1" />
|
||||||
|
<view class="vsm-wave vsm-wave--2" />
|
||||||
|
<view class="vsm-wave vsm-wave--3" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
/** 正在按住说话 / 录音中 */
|
||||||
|
active: { type: Boolean, default: false },
|
||||||
|
/** 深色条背景上使用浅色卡通 */
|
||||||
|
onDark: { type: Boolean, default: false },
|
||||||
|
size: { type: String, default: 'md' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.vsm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vsm--md { width: 72rpx; height: 72rpx; }
|
||||||
|
.vsm--sm { width: 64rpx; height: 64rpx; }
|
||||||
|
|
||||||
|
.vsm-body {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-leaf {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rpx;
|
||||||
|
left: 50%;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
margin-left: -8rpx;
|
||||||
|
border-radius: 0 100% 0 100%;
|
||||||
|
background: #34d399;
|
||||||
|
transform: rotate(-18deg);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-leaf {
|
||||||
|
background: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-face {
|
||||||
|
position: relative;
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fef9c3;
|
||||||
|
border: 3rpx solid #047857;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-face {
|
||||||
|
width: 46rpx;
|
||||||
|
height: 46rpx;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-face {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-eye {
|
||||||
|
position: absolute;
|
||||||
|
top: 16rpx;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #064e3b;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
|
||||||
|
.vsm--on-dark .vsm-eye { background: #134e4a; }
|
||||||
|
.vsm-eye--l { left: 12rpx; }
|
||||||
|
.vsm-eye--r { right: 12rpx; }
|
||||||
|
.vsm--sm .vsm-eye--l { left: 10rpx; }
|
||||||
|
.vsm--sm .vsm-eye--r { right: 10rpx; }
|
||||||
|
|
||||||
|
.vsm-blush {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
width: 10rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(251, 113, 133, 0.45);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
|
||||||
|
.vsm-blush--l { left: 6rpx; }
|
||||||
|
.vsm-blush--r { right: 6rpx; }
|
||||||
|
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
|
||||||
|
|
||||||
|
.vsm-mouth {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 18rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
margin-left: -9rpx;
|
||||||
|
border-radius: 0 0 18rpx 18rpx;
|
||||||
|
background: #047857;
|
||||||
|
transform-origin: center top;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-mouth {
|
||||||
|
bottom: 9rpx;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 7rpx;
|
||||||
|
margin-left: -8rpx;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
|
||||||
|
|
||||||
|
.vsm-waves {
|
||||||
|
position: absolute;
|
||||||
|
right: -2rpx;
|
||||||
|
top: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
margin-top: -14rpx;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.vsm-wave {
|
||||||
|
width: 5rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: #047857;
|
||||||
|
animation: vsm-wave-jump 0.72s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
|
||||||
|
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
|
||||||
|
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
|
||||||
|
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
|
||||||
|
|
||||||
|
.vsm--active .vsm-body {
|
||||||
|
animation: vsm-bob 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vsm--active .vsm-mouth {
|
||||||
|
animation: vsm-talk 0.32s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.vsm--active .vsm-eye {
|
||||||
|
animation: vsm-blink 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vsm-bob {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-4rpx); }
|
||||||
|
}
|
||||||
|
@keyframes vsm-talk {
|
||||||
|
0% {
|
||||||
|
transform: scaleY(0.45);
|
||||||
|
border-radius: 0 0 18rpx 18rpx;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1.15);
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes vsm-blink {
|
||||||
|
0%, 42%, 46%, 100% { transform: scaleY(1); }
|
||||||
|
44% { transform: scaleY(0.15); }
|
||||||
|
}
|
||||||
|
@keyframes vsm-wave-jump {
|
||||||
|
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
|
||||||
|
50% { transform: scaleY(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
|
||||||
|
|
||||||
|
const emptyBloodCarePlan = () => ({
|
||||||
|
summary: '',
|
||||||
|
blood_advice: '',
|
||||||
|
tcm_plan: '',
|
||||||
|
watch_points: [],
|
||||||
|
next_steps: '',
|
||||||
|
control_level: '',
|
||||||
|
control_label: '',
|
||||||
|
disclaimer: '',
|
||||||
|
analysis: [],
|
||||||
|
source: '',
|
||||||
|
date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** AI 血糖照护建议(累计 7 天有记录后) */
|
||||||
|
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
|
||||||
|
const bloodCarePlan = ref(emptyBloodCarePlan())
|
||||||
|
const bloodCareLoading = ref(false)
|
||||||
|
const bloodCareStreaming = ref(false)
|
||||||
|
const bloodCareStreamField = ref('')
|
||||||
|
let careStreamBuffer = ''
|
||||||
|
/** 用户点击后才展开照护建议卡片 */
|
||||||
|
const bloodCarePanelOpen = ref(false)
|
||||||
|
|
||||||
|
const bloodCareEligible = computed(() => {
|
||||||
|
return !!diagnosisId.value && !chartUseMockData.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasBloodCareContent = computed(() => {
|
||||||
|
const p = bloodCarePlan.value
|
||||||
|
return !!(p.summary || p.blood_advice || p.tcm_plan)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bloodCareAnalysis = computed(() => {
|
||||||
|
const list = bloodCarePlan.value?.analysis
|
||||||
|
return Array.isArray(list) ? list : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const bloodCareControlLabel = computed(() => {
|
||||||
|
const label = String(bloodCarePlan.value?.control_label || '').trim()
|
||||||
|
return label
|
||||||
|
})
|
||||||
|
|
||||||
|
const bloodCareDisclaimer = computed(() => {
|
||||||
|
return String(bloodCarePlan.value?.disclaimer || '').trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
function detectCareStreamField(text) {
|
||||||
|
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[::]\s*([^\n]*)$/)
|
||||||
|
if (!tail) return 'summary'
|
||||||
|
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
|
||||||
|
return map[tail[1]] || 'summary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePartialCareFromStream() {
|
||||||
|
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
|
||||||
|
const next = { ...bloodCarePlan.value }
|
||||||
|
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
|
||||||
|
if (partial[key] != null) next[key] = partial[key]
|
||||||
|
}
|
||||||
|
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
|
||||||
|
bloodCarePlan.value = next
|
||||||
|
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBloodCarePayload(payload) {
|
||||||
|
if (!payload) return
|
||||||
|
const prevAnalysis = bloodCarePlan.value.analysis
|
||||||
|
bloodCarePlan.value = {
|
||||||
|
...emptyBloodCarePlan(),
|
||||||
|
...payload,
|
||||||
|
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
|
||||||
|
analysis: Array.isArray(payload.analysis) && payload.analysis.length
|
||||||
|
? payload.analysis
|
||||||
|
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBloodCareStreamEvent(event, payload) {
|
||||||
|
if (event === 'analysis') {
|
||||||
|
if (Array.isArray(payload?.analysis)) {
|
||||||
|
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
|
||||||
|
}
|
||||||
|
} else if (event === 'delta') {
|
||||||
|
bloodCareStreaming.value = true
|
||||||
|
if (payload.buffer != null) {
|
||||||
|
careStreamBuffer = payload.buffer
|
||||||
|
} else {
|
||||||
|
careStreamBuffer += payload.text || ''
|
||||||
|
}
|
||||||
|
mergePartialCareFromStream()
|
||||||
|
} else if (event === 'done') {
|
||||||
|
applyBloodCarePayload(payload)
|
||||||
|
careStreamBuffer = ''
|
||||||
|
bloodCareStreamField.value = ''
|
||||||
|
} else if (event === 'error') {
|
||||||
|
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBloodCareAiFallback(refresh) {
|
||||||
|
const res = await proxy.apiUrl({
|
||||||
|
url: '/api/tcm/dailyBloodCareAiRecommend',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 35000,
|
||||||
|
data: {
|
||||||
|
diagnosis_id: diagnosisId.value,
|
||||||
|
refresh: refresh ? 1 : 0,
|
||||||
|
...(refresh ? { _t: Date.now() } : {})
|
||||||
|
}
|
||||||
|
}, false)
|
||||||
|
if (res?.code === 1 && res.data) {
|
||||||
|
applyBloodCarePayload(res.data)
|
||||||
|
} else if (res?.msg) {
|
||||||
|
showUserToast(res.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBloodCarePanel() {
|
||||||
|
bloodCarePanelOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
|
||||||
|
async function toggleBloodCareFromChart() {
|
||||||
|
if (!bloodCareEligible.value || bloodCareLoading.value) return
|
||||||
|
if (bloodCarePanelOpen.value) {
|
||||||
|
closeBloodCarePanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bloodCarePanelOpen.value = true
|
||||||
|
if (!hasBloodCareContent.value) {
|
||||||
|
await fetchBloodCareAi(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBloodCareAi(refresh = false) {
|
||||||
|
if (!bloodCareEligible.value || bloodCareLoading.value) return
|
||||||
|
bloodCareLoading.value = true
|
||||||
|
bloodCareStreaming.value = false
|
||||||
|
careStreamBuffer = ''
|
||||||
|
bloodCareStreamField.value = ''
|
||||||
|
if (refresh) {
|
||||||
|
applyBloodCarePayload(emptyBloodCarePlan())
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestAiStream({
|
||||||
|
baseUrl: proxy.$url,
|
||||||
|
url: '/api/tcm/dailyBloodCareAiRecommendStream',
|
||||||
|
method: 'GET',
|
||||||
|
data: {
|
||||||
|
diagnosis_id: diagnosisId.value,
|
||||||
|
refresh: refresh ? 1 : 0,
|
||||||
|
...(refresh ? { _t: Date.now() } : {})
|
||||||
|
},
|
||||||
|
onEvent: onBloodCareStreamEvent,
|
||||||
|
fallback: () => fetchBloodCareAiFallback(refresh)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await fetchBloodCareAiFallback(refresh)
|
||||||
|
} catch (e2) {
|
||||||
|
showUserToast('照护建议加载失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bloodCareLoading.value = false
|
||||||
|
bloodCareStreaming.value = false
|
||||||
|
bloodCareStreamField.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshBloodCareAi() {
|
||||||
|
if (bloodCareLoading.value) return
|
||||||
|
fetchBloodCareAi(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBloodCarePlan() {
|
||||||
|
applyBloodCarePayload(emptyBloodCarePlan())
|
||||||
|
careStreamBuffer = ''
|
||||||
|
bloodCareStreamField.value = ''
|
||||||
|
bloodCarePanelOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bloodCarePlan,
|
||||||
|
bloodCareLoading,
|
||||||
|
bloodCareStreaming,
|
||||||
|
bloodCareStreamField,
|
||||||
|
bloodCareEligible,
|
||||||
|
bloodCarePanelOpen,
|
||||||
|
hasBloodCareContent,
|
||||||
|
bloodCareAnalysis,
|
||||||
|
bloodCareControlLabel,
|
||||||
|
bloodCareDisclaimer,
|
||||||
|
fetchBloodCareAi,
|
||||||
|
toggleBloodCareFromChart,
|
||||||
|
closeBloodCarePanel,
|
||||||
|
refreshBloodCareAi,
|
||||||
|
resetBloodCarePlan
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
|
|||||||
|
|
||||||
const emptyDietAiRecommend = () => ({
|
const emptyDietAiRecommend = () => ({
|
||||||
breakfast: '',
|
breakfast: '',
|
||||||
|
drinks: '',
|
||||||
lunch: '',
|
lunch: '',
|
||||||
dinner: '',
|
dinner: '',
|
||||||
tips: '',
|
tips: '',
|
||||||
@@ -31,20 +32,20 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
|||||||
|
|
||||||
const hasDietAiMealContent = computed(() => {
|
const hasDietAiMealContent = computed(() => {
|
||||||
const r = dietAiRecommend.value
|
const r = dietAiRecommend.value
|
||||||
return !!(r.breakfast || r.lunch || r.dinner || r.tips)
|
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
|
||||||
})
|
})
|
||||||
|
|
||||||
function detectDietStreamField(text) {
|
function detectDietStreamField(text) {
|
||||||
const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||||
if (!tail) return 'breakfast'
|
if (!tail) return 'breakfast'
|
||||||
const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
||||||
return map[tail[1]] || 'breakfast'
|
return map[tail[1]] || 'breakfast'
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergePartialDietFromStream() {
|
function mergePartialDietFromStream() {
|
||||||
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
||||||
const next = { ...dietAiRecommend.value }
|
const next = { ...dietAiRecommend.value }
|
||||||
for (const key of ['breakfast', 'lunch', 'dinner', 'tips']) {
|
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
|
||||||
if (partial[key] != null) next[key] = partial[key]
|
if (partial[key] != null) next[key] = partial[key]
|
||||||
}
|
}
|
||||||
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
||||||
@@ -80,6 +81,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
|||||||
...emptyDietAiRecommend(),
|
...emptyDietAiRecommend(),
|
||||||
...payload,
|
...payload,
|
||||||
breakfast: streamed.breakfast,
|
breakfast: streamed.breakfast,
|
||||||
|
drinks: streamed.drinks || payload.drinks || '',
|
||||||
lunch: streamed.lunch || payload.lunch || '',
|
lunch: streamed.lunch || payload.lunch || '',
|
||||||
dinner: streamed.dinner || payload.dinner || '',
|
dinner: streamed.dinner || payload.dinner || '',
|
||||||
tips: streamed.tips || payload.tips || '',
|
tips: streamed.tips || payload.tips || '',
|
||||||
@@ -205,6 +207,8 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!diagnosisId.value) return
|
if (!diagnosisId.value) return
|
||||||
|
// 发送后立即清空输入框
|
||||||
|
dietAiAskText.value = ''
|
||||||
dietAiAsking.value = true
|
dietAiAsking.value = true
|
||||||
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
||||||
try {
|
try {
|
||||||
@@ -259,6 +263,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
|||||||
try {
|
try {
|
||||||
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
||||||
breakfast: r.breakfast,
|
breakfast: r.breakfast,
|
||||||
|
drinks: r.drinks || '',
|
||||||
lunch: r.lunch,
|
lunch: r.lunch,
|
||||||
dinner: r.dinner,
|
dinner: r.dinner,
|
||||||
tips: r.tips || ''
|
tips: r.tips || ''
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const MIN_HOLD_MS = 280
|
||||||
|
|
||||||
|
/** 短按/过早松手时插件常见错误,不应打扰用户 */
|
||||||
|
function isBenignSttError(msg, retcode) {
|
||||||
|
const m = String(msg || '').toLowerCase()
|
||||||
|
if (/internal voice data failed/i.test(m)) return true
|
||||||
|
if (/voice data failed/i.test(m)) return true
|
||||||
|
if (/recordfailed|record failed|record manager/i.test(m)) return true
|
||||||
|
if (/please stop after start/i.test(m)) return true
|
||||||
|
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
|
||||||
|
const code = Number(retcode)
|
||||||
|
if (code === -30002 || code === -30003 || code === -30012) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlySttError(msg) {
|
||||||
|
const m = String(msg || '').trim()
|
||||||
|
if (!m || isBenignSttError(m)) return ''
|
||||||
|
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
|
||||||
|
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
|
||||||
|
return '语音识别失败,请重试'
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
||||||
|
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
||||||
|
// 回调仍指向已销毁页面的闭包,新页面收不到 onStart/onStop,
|
||||||
|
// 其本地状态卡在 recording,表现为一直录制/无法再次语音。
|
||||||
|
let sharedManager = null
|
||||||
|
let sharedManagerInited = false
|
||||||
|
let sharedRecording = false
|
||||||
|
let activeCtl = null
|
||||||
|
|
||||||
|
function getSharedRecordManager() {
|
||||||
|
if (sharedManagerInited) return sharedManager
|
||||||
|
sharedManagerInited = true
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const plugin = requirePlugin('WechatSI')
|
||||||
|
sharedManager = plugin.getRecordRecognitionManager()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('WechatSI record recognition not available', e)
|
||||||
|
sharedManager = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!sharedManager) return null
|
||||||
|
|
||||||
|
sharedManager.onStart = () => {
|
||||||
|
sharedRecording = true
|
||||||
|
if (activeCtl) activeCtl.handleStart()
|
||||||
|
}
|
||||||
|
sharedManager.onRecognize = (res) => {
|
||||||
|
if (activeCtl) activeCtl.handleRecognize(res)
|
||||||
|
}
|
||||||
|
sharedManager.onStop = (res) => {
|
||||||
|
sharedRecording = false
|
||||||
|
if (activeCtl) activeCtl.handleStop(res)
|
||||||
|
}
|
||||||
|
sharedManager.onError = (res) => {
|
||||||
|
sharedRecording = false
|
||||||
|
if (activeCtl) activeCtl.handleError(res)
|
||||||
|
}
|
||||||
|
return sharedManager
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* onResult?: (text: string) => void,
|
||||||
|
* onSettle?: (text: string, info: { heldMs: number }) => boolean | void,
|
||||||
|
* onPartial?: (text: string) => void,
|
||||||
|
* onError?: (msg: string) => boolean | void,
|
||||||
|
* showToast?: (msg: string) => void
|
||||||
|
* }} options
|
||||||
|
*
|
||||||
|
* onSettle 在每次录音结束时都会触发(含空结果),用于语音一问一答等需要
|
||||||
|
* 完全接管识别结果的场景。若 onSettle 返回 true,则跳过默认的 onResult 回调
|
||||||
|
* 与「没听清」提示,避免与上层流程重复处理。
|
||||||
|
*
|
||||||
|
* onPartial 在录音过程中实时返回中间识别结果(微信 onRecognize / H5 interim),
|
||||||
|
* 用于「抢答打断」等场景:检测到用户已开口作答即可提前结束播报/录音。
|
||||||
|
*
|
||||||
|
* onError 在录音/识别出错时触发(如 "record manager recordfailed")。
|
||||||
|
* 若返回 true,则跳过默认的错误 Toast,由上层自行处理(如自动重试)。
|
||||||
|
*/
|
||||||
|
export function useSpeechToText({ onResult, onSettle, onPartial, onError, showToast } = {}) {
|
||||||
|
const sttListening = ref(false)
|
||||||
|
const sttHolding = ref(false)
|
||||||
|
const sttSupported = ref(false)
|
||||||
|
|
||||||
|
let wxRecordManager = null
|
||||||
|
let h5Recognition = null
|
||||||
|
let h5GotResult = false
|
||||||
|
let holdStartTs = 0
|
||||||
|
let holdSessionId = 0
|
||||||
|
let startRequested = false
|
||||||
|
let recordAuthorized = null
|
||||||
|
// 快速点按可能残留会话:用排队标记把 start/stop 串行化,
|
||||||
|
// 避免 "please stop after start"
|
||||||
|
let pendingStartSession = 0
|
||||||
|
|
||||||
|
function notify(msg) {
|
||||||
|
if (!msg) return
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showToast({ title: msg, icon: 'none', duration: 2000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitResult(text) {
|
||||||
|
const t = String(text || '').trim()
|
||||||
|
const heldMs = Date.now() - holdStartTs
|
||||||
|
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
|
||||||
|
if (typeof onSettle === 'function') {
|
||||||
|
const handled = onSettle(t, { heldMs })
|
||||||
|
if (handled === true) return
|
||||||
|
}
|
||||||
|
if (!t) {
|
||||||
|
if (heldMs < MIN_HOLD_MS) return
|
||||||
|
notify('没听清,请再试一次')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof onResult === 'function') {
|
||||||
|
onResult(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopListening() {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
const ownsRecording = sharedRecording && activeCtl === controller
|
||||||
|
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
|
||||||
|
if (wxRecordManager && (sttListening.value || ownsRecording)) {
|
||||||
|
try {
|
||||||
|
wxRecordManager.stop()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
if (h5Recognition && sttListening.value) {
|
||||||
|
try {
|
||||||
|
h5Recognition.stop()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
startRequested = false
|
||||||
|
sttHolding.value = false
|
||||||
|
sttListening.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRecordAuth(onGranted) {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
if (recordAuthorized === true) {
|
||||||
|
onGranted()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.authorize({
|
||||||
|
scope: 'scope.record',
|
||||||
|
success() {
|
||||||
|
recordAuthorized = true
|
||||||
|
onGranted()
|
||||||
|
},
|
||||||
|
fail() {
|
||||||
|
recordAuthorized = false
|
||||||
|
sttHolding.value = false
|
||||||
|
uni.showModal({
|
||||||
|
title: '需要麦克风权限',
|
||||||
|
content: '请在设置中允许使用麦克风,以便长按说话',
|
||||||
|
confirmText: '去设置',
|
||||||
|
success(res) {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.openSetting({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
onGranted()
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
function actuallyStartWechat(session) {
|
||||||
|
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startRequested = true
|
||||||
|
wxRecordManager.start({
|
||||||
|
duration: 60000,
|
||||||
|
lang: 'zh_CN'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
startRequested = false
|
||||||
|
sharedRecording = false
|
||||||
|
sttHolding.value = false
|
||||||
|
notify('无法开始录音')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
function startWechatRecord(session) {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 上一段会话还没结束(含全局单例残留):先停止并排队,
|
||||||
|
// 待 onStop 回调后再真正开始,避免 "please stop after start"
|
||||||
|
if (sharedRecording || startRequested) {
|
||||||
|
pendingStartSession = session
|
||||||
|
try {
|
||||||
|
wxRecordManager.stop()
|
||||||
|
} catch (e) {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actuallyStartWechat(session)
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function startH5Record() {
|
||||||
|
// #ifdef H5
|
||||||
|
if (!h5Recognition) {
|
||||||
|
notify('当前浏览器不支持语音输入')
|
||||||
|
sttHolding.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startRequested = true
|
||||||
|
h5GotResult = false
|
||||||
|
h5Recognition.start()
|
||||||
|
sttListening.value = true
|
||||||
|
} catch (e) {
|
||||||
|
startRequested = false
|
||||||
|
sttHolding.value = false
|
||||||
|
notify('无法开始语音识别,请检查麦克风权限')
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 长按开始:手指按下 */
|
||||||
|
function beginHoldSpeech() {
|
||||||
|
if (!sttSupported.value) {
|
||||||
|
notify('当前环境不支持语音输入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sttHolding.value) return
|
||||||
|
|
||||||
|
const session = ++holdSessionId
|
||||||
|
holdStartTs = Date.now()
|
||||||
|
sttHolding.value = true
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 把全局录音回调切到当前实例
|
||||||
|
activeCtl = controller
|
||||||
|
ensureRecordAuth(() => startWechatRecord(session))
|
||||||
|
// #endif
|
||||||
|
// #ifdef H5
|
||||||
|
startH5Record()
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
// #ifndef H5
|
||||||
|
sttHolding.value = false
|
||||||
|
notify('当前端暂不支持语音输入')
|
||||||
|
// #endif
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 松手结束:手指抬起;force=true 时强制取消尚未真正开始的按住会话 */
|
||||||
|
function endHoldSpeech(force = false) {
|
||||||
|
let recording = false
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
recording = sharedRecording && activeCtl === controller
|
||||||
|
// #endif
|
||||||
|
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sttHolding.value = false
|
||||||
|
// 松手后不再补开排队的录音
|
||||||
|
pendingStartSession = 0
|
||||||
|
if (sttListening.value || recording) {
|
||||||
|
stopListening()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (startRequested) {
|
||||||
|
// start 已发出但 onStart 未到:取消会话,勿调 stop()
|
||||||
|
startRequested = false
|
||||||
|
holdSessionId += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holdSessionId += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
|
||||||
|
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
|
||||||
|
const controller = {
|
||||||
|
handleStart() {
|
||||||
|
if (!sttHolding.value) {
|
||||||
|
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
|
||||||
|
startRequested = false
|
||||||
|
sttListening.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startRequested = false
|
||||||
|
sttListening.value = true
|
||||||
|
},
|
||||||
|
handleRecognize(res) {
|
||||||
|
if (typeof onPartial !== 'function') return
|
||||||
|
const t = String(res?.result || '').trim()
|
||||||
|
if (t) onPartial(t)
|
||||||
|
},
|
||||||
|
handleStop(res) {
|
||||||
|
sttListening.value = false
|
||||||
|
startRequested = false
|
||||||
|
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
|
||||||
|
if (pendingStartSession && pendingStartSession === holdSessionId && sttHolding.value) {
|
||||||
|
const s = pendingStartSession
|
||||||
|
pendingStartSession = 0
|
||||||
|
actuallyStartWechat(s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingStartSession = 0
|
||||||
|
emitResult(res?.result)
|
||||||
|
},
|
||||||
|
handleError(res) {
|
||||||
|
sttListening.value = false
|
||||||
|
startRequested = false
|
||||||
|
const msg = (res && res.msg) || ''
|
||||||
|
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
|
||||||
|
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
|
||||||
|
if (/please stop after start/i.test(msg)) {
|
||||||
|
try {
|
||||||
|
wxRecordManager.stop()
|
||||||
|
} catch (e) {}
|
||||||
|
pendingStartSession = sttHolding.value ? holdSessionId : 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingStartSession = 0
|
||||||
|
sttHolding.value = false
|
||||||
|
const heldMs = Date.now() - holdStartTs
|
||||||
|
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
|
||||||
|
if (typeof onError === 'function') onError(msg || '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
||||||
|
if (typeof onError === 'function') {
|
||||||
|
const handled = onError(msg || '')
|
||||||
|
if (handled === true) return
|
||||||
|
}
|
||||||
|
const tip = friendlySttError(msg)
|
||||||
|
if (tip) notify(tip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wxRecordManager = getSharedRecordManager()
|
||||||
|
sttSupported.value = !!wxRecordManager
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||||
|
if (SR) {
|
||||||
|
h5Recognition = new SR()
|
||||||
|
h5Recognition.lang = 'zh-CN'
|
||||||
|
h5Recognition.interimResults = true
|
||||||
|
h5Recognition.continuous = false
|
||||||
|
h5Recognition.maxAlternatives = 1
|
||||||
|
sttSupported.value = true
|
||||||
|
|
||||||
|
h5Recognition.onresult = (event) => {
|
||||||
|
let interim = ''
|
||||||
|
let finalText = ''
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const r = event.results[i]
|
||||||
|
const txt = r?.[0]?.transcript || ''
|
||||||
|
if (r.isFinal) finalText += txt
|
||||||
|
else interim += txt
|
||||||
|
}
|
||||||
|
if (interim && typeof onPartial === 'function') onPartial(interim)
|
||||||
|
if (finalText) {
|
||||||
|
h5GotResult = true
|
||||||
|
emitResult(finalText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h5Recognition.onend = () => {
|
||||||
|
sttListening.value = false
|
||||||
|
startRequested = false
|
||||||
|
sttHolding.value = false
|
||||||
|
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
|
||||||
|
if (!h5GotResult) {
|
||||||
|
emitResult('')
|
||||||
|
}
|
||||||
|
h5GotResult = false
|
||||||
|
}
|
||||||
|
h5Recognition.onerror = (event) => {
|
||||||
|
sttListening.value = false
|
||||||
|
startRequested = false
|
||||||
|
sttHolding.value = false
|
||||||
|
const err = event?.error || ''
|
||||||
|
if (err === 'aborted') return
|
||||||
|
if (typeof onError === 'function') {
|
||||||
|
const handled = onError(err)
|
||||||
|
if (handled === true) return
|
||||||
|
}
|
||||||
|
if (err === 'not-allowed') {
|
||||||
|
notify('请允许浏览器使用麦克风')
|
||||||
|
} else {
|
||||||
|
notify('语音识别失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('H5 SpeechRecognition not available', e)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
sttHolding.value = false
|
||||||
|
holdSessionId += 1
|
||||||
|
pendingStartSession = 0
|
||||||
|
stopListening()
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 释放全局回调指向,避免事件继续派发给已销毁的实例
|
||||||
|
if (activeCtl === controller) {
|
||||||
|
activeCtl = null
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
sttListening,
|
||||||
|
sttHolding,
|
||||||
|
sttSupported,
|
||||||
|
beginHoldSpeech,
|
||||||
|
endHoldSpeech,
|
||||||
|
stopSpeechToText: endHoldSpeech
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isBenignSttError, friendlySttError }
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
<!DOCTYPE html><html class="light" lang="zh-CN"><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<title>Daily Care - Health Dashboard</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
"colors": {
|
||||||
|
"secondary-fixed": "#d9e6dd",
|
||||||
|
"surface-container": "#e8f0e9",
|
||||||
|
"surface-dim": "#d4dcd5",
|
||||||
|
"surface-variant": "#dde4dd",
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"inverse-surface": "#2b322d",
|
||||||
|
"secondary": "#55615a",
|
||||||
|
"primary": "#006c49",
|
||||||
|
"on-secondary-fixed": "#131e19",
|
||||||
|
"surface-bright": "#f4fbf4",
|
||||||
|
"on-tertiary-container": "#711419",
|
||||||
|
"on-primary-fixed-variant": "#005236",
|
||||||
|
"outline-variant": "#bbcabf",
|
||||||
|
"primary-container": "#10b981",
|
||||||
|
"surface": "#f4fbf4",
|
||||||
|
"on-tertiary-fixed-variant": "#842225",
|
||||||
|
"tertiary-fixed-dim": "#ffb3af",
|
||||||
|
"tertiary-fixed": "#ffdad7",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-container-highest": "#dde4dd",
|
||||||
|
"primary-fixed": "#6ffbbe",
|
||||||
|
"on-primary-container": "#00422b",
|
||||||
|
"secondary-fixed-dim": "#bdcac1",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"on-primary-fixed": "#002113",
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"tertiary-container": "#fc7c78",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"inverse-primary": "#4edea3",
|
||||||
|
"inverse-on-surface": "#ebf3eb",
|
||||||
|
"on-secondary-container": "#5b6760",
|
||||||
|
"surface-container-high": "#e3eae3",
|
||||||
|
"surface-container-low": "#eef6ee",
|
||||||
|
"on-secondary-fixed-variant": "#3e4943",
|
||||||
|
"outline": "#6c7a71",
|
||||||
|
"secondary-container": "#d9e6dd",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"on-surface": "#161d19",
|
||||||
|
"on-background": "#161d19",
|
||||||
|
"tertiary": "#a43a3a",
|
||||||
|
"primary-fixed-dim": "#4edea3",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"background": "#f4fbf4",
|
||||||
|
"on-surface-variant": "#3c4a42",
|
||||||
|
"surface-tint": "#006c49",
|
||||||
|
"on-tertiary-fixed": "#410005"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0.25rem",
|
||||||
|
"lg": "0.5rem",
|
||||||
|
"xl": "0.75rem",
|
||||||
|
"2xl": "1rem",
|
||||||
|
"3xl": "1.5rem",
|
||||||
|
"full": "9999px"
|
||||||
|
},
|
||||||
|
"spacing": {
|
||||||
|
"card-padding": "20px",
|
||||||
|
"section-margin": "32px",
|
||||||
|
"inline-gap": "12px",
|
||||||
|
"stack-gap": "16px",
|
||||||
|
"container-margin": "20px"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"headline-md": ["Manrope"],
|
||||||
|
"headline-lg-mobile": ["Manrope"],
|
||||||
|
"body-lg": ["Manrope"],
|
||||||
|
"headline-lg": ["Manrope"],
|
||||||
|
"display-lg": ["Manrope"],
|
||||||
|
"body-md": ["Manrope"],
|
||||||
|
"label-md": ["Manrope"]
|
||||||
|
},
|
||||||
|
"fontSize": {
|
||||||
|
"headline-md": ["20px", {"lineHeight": "28px", "fontWeight": "700"}],
|
||||||
|
"headline-lg-mobile": ["22px", {"lineHeight": "28px", "fontWeight": "700"}],
|
||||||
|
"body-lg": ["16px", {"lineHeight": "24px", "fontWeight": "500"}],
|
||||||
|
"headline-lg": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
|
||||||
|
"display-lg": ["32px", {"lineHeight": "40px", "letterSpacing": "-0.02em", "fontWeight": "800"}],
|
||||||
|
"body-md": ["14px", {"lineHeight": "20px", "fontWeight": "400"}],
|
||||||
|
"label-md": ["12px", {"lineHeight": "16px", "letterSpacing": "0.02em", "fontWeight": "600"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Manrope', sans-serif; -webkit-tap-highlight-color: transparent; }
|
||||||
|
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; display: inline-block; vertical-align: middle; }
|
||||||
|
.ambient-shadow { box-shadow: 0 20px 30px -10px rgba(0, 108, 73, 0.08); }
|
||||||
|
.active-scale:active { transform: scale(0.98); transition: all 0.2s ease; }
|
||||||
|
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
|
||||||
|
/* Watering Animation Styles */
|
||||||
|
@keyframes fall {
|
||||||
|
0% { transform: translateY(-20px); opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { transform: translateY(40px); opacity: 0; }
|
||||||
|
}
|
||||||
|
.droplet {
|
||||||
|
position: absolute;
|
||||||
|
width: 4px;
|
||||||
|
height: 10px;
|
||||||
|
background: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: fall 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-growth {
|
||||||
|
0% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
|
||||||
|
50% { transform: scale(1.15); filter: drop-shadow(0 0 15px rgba(78, 222, 163, 0.6)); }
|
||||||
|
100% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
|
||||||
|
}
|
||||||
|
.growth-pulse {
|
||||||
|
animation: pulse-growth 0.8s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { opacity: 0; transform: scale(0) rotate(0deg); }
|
||||||
|
50% { opacity: 1; transform: scale(1.2) rotate(180deg); }
|
||||||
|
100% { opacity: 0; transform: scale(0) rotate(360deg); }
|
||||||
|
}
|
||||||
|
.shimmer-particle {
|
||||||
|
position: absolute;
|
||||||
|
color: #4edea3;
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: shimmer 1s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blood Sugar Decrease Animation */
|
||||||
|
@keyframes float-up-fade {
|
||||||
|
0% { transform: translateY(20px); opacity: 0; }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
80% { opacity: 1; }
|
||||||
|
100% { transform: translateY(-100px); opacity: 0; }
|
||||||
|
}
|
||||||
|
.sugar-decrease-text {
|
||||||
|
position: fixed;
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 24px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
text-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||||
|
animation: float-up-fade 2s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes fall-arrow {
|
||||||
|
0% { transform: translateY(-50vh) scale(0.5); opacity: 0; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
100% { transform: translateY(110vh) scale(1.2); opacity: 0; }
|
||||||
|
}
|
||||||
|
.sugar-particle {
|
||||||
|
position: fixed;
|
||||||
|
color: #10b981;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 90;
|
||||||
|
animation: fall-arrow 1.5s linear forwards;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
min-height: max(884px, 100dvh);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-on-background min-h-screen pb-24">
|
||||||
|
<!-- Top App Bar -->
|
||||||
|
<header class="bg-primary docked full-width top-0 h-48 flex items-end pb-6 px-5 fixed left-0 right-0 z-40">
|
||||||
|
<div class="flex justify-between items-center w-full">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full border-2 border-on-primary overflow-hidden">
|
||||||
|
<img class="w-full h-full object-cover" data-alt="A professional studio portrait" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCXIPbeivladmw0-SmLno2vkwVvsObW2D5HUtodItlBnxdhFF8TvZdQYrTt14QCk3n5VXXIpdk3cuWTJHXJgDXd_clQyjLsb477e5h2313zh76yRVns01Q5pLpM-2_RbheRcUioLvObwJtIPJbkDcbkNA61-LyBnbStRcPoh6YOvNHSHjUUR_nDJpPm18ga87ggphbFWF-pt7lRF027swmyitUeVF7HXQDfcG7Mq7E0pqsbhyA7jKxtzmbN-HLh7GIrhc6LOAUaXiX9">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-on-primary font-bold text-headline-lg-mobile leading-tight">Daily Care</h1>
|
||||||
|
<p class="text-on-primary/80 text-body-md">吕帅</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary rounded-full px-4 py-1.5 flex items-center gap-2 active-scale transition-all">
|
||||||
|
<span class="material-symbols-outlined text-[18px]" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
|
||||||
|
<span class="text-label-md">血糖</span>
|
||||||
|
</button>
|
||||||
|
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary w-10 h-10 rounded-full flex items-center justify-center active-scale transition-all">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="pt-52 px-container-margin space-y-6">
|
||||||
|
<!-- Toggle Switch -->
|
||||||
|
<div class="bg-surface-container-high rounded-full p-1 flex items-center">
|
||||||
|
<button class="flex-1 bg-primary text-on-primary rounded-full py-2 text-label-md font-bold shadow-md transition-all">最近 7 天</button>
|
||||||
|
<button class="flex-1 text-on-surface-variant py-2 text-label-md font-bold transition-all">最近 30 天</button>
|
||||||
|
</div>
|
||||||
|
<!-- Health Calendar -->
|
||||||
|
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-headline-md font-headline-md text-on-surface">健康日历</h2>
|
||||||
|
<p class="text-body-md text-outline">2026年5月 · 已记录 3 / 7 天</p>
|
||||||
|
</div>
|
||||||
|
<button class="text-primary"><span class="material-symbols-outlined">chevron_right</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-7 gap-1 text-center mb-4">
|
||||||
|
<span class="text-label-md text-outline">一</span>
|
||||||
|
<span class="text-label-md text-outline">二</span>
|
||||||
|
<span class="text-label-md text-outline">三</span>
|
||||||
|
<span class="text-label-md text-outline">四</span>
|
||||||
|
<span class="text-label-md text-outline">五</span>
|
||||||
|
<span class="text-label-md text-outline">六</span>
|
||||||
|
<span class="text-label-md text-tertiary">日</span>
|
||||||
|
<span class="py-2 text-body-md text-outline/30">18</span>
|
||||||
|
<span class="py-2 text-body-md text-outline/30">19</span>
|
||||||
|
<span class="py-2 text-body-md text-outline/30">20</span>
|
||||||
|
<span class="py-2 text-body-md text-outline/30">21</span>
|
||||||
|
<span class="py-2 text-body-md text-outline/30">22</span>
|
||||||
|
<div class="py-2 relative"><span class="text-body-md font-bold text-on-surface">23</span></div>
|
||||||
|
<div class="py-2 relative bg-primary-container/10 rounded-lg">
|
||||||
|
<span class="text-body-md font-bold text-primary">24</span>
|
||||||
|
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
|
||||||
|
</div>
|
||||||
|
<div class="py-2 relative bg-primary-container/10 rounded-lg">
|
||||||
|
<span class="text-body-md font-bold text-primary">25</span>
|
||||||
|
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
|
||||||
|
</div>
|
||||||
|
<div class="py-2 relative bg-primary-container/10 rounded-lg">
|
||||||
|
<span class="text-body-md font-bold text-primary">26</span>
|
||||||
|
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
|
||||||
|
</div>
|
||||||
|
<span class="py-2 text-body-md text-on-surface">27</span>
|
||||||
|
<span class="py-2 text-body-md text-on-surface">28</span>
|
||||||
|
<div class="py-2 relative bg-tertiary-container rounded-lg">
|
||||||
|
<span class="text-body-md font-bold text-on-tertiary">29</span>
|
||||||
|
</div>
|
||||||
|
<span class="py-2 text-body-md text-outline/40">30</span>
|
||||||
|
<span class="py-2 text-body-md text-outline/40">31</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between border-t border-outline-variant/30 pt-4 mt-2">
|
||||||
|
<span class="text-body-md text-outline">今日还没有记录</span>
|
||||||
|
<button class="bg-primary text-on-primary px-6 py-3 rounded-2xl text-label-md font-bold active-scale transition-all flex items-center gap-2">去记血糖</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Blood Sugar Details Section -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-headline-md font-headline-md text-on-surface">血糖明细</h3>
|
||||||
|
<span class="text-body-md text-outline">共 3 天</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<span class="text-headline-md font-bold">05-26</span>
|
||||||
|
<span class="text-body-md text-outline">周二</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
|
||||||
|
<span class="text-body-md text-outline">空腹</span>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-headline-lg font-extrabold text-error">10.4</span>
|
||||||
|
<span class="text-label-md text-outline ml-1">mmol/L</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
|
||||||
|
<span class="text-body-md text-outline">餐后</span>
|
||||||
|
<span class="text-headline-md font-bold text-outline-variant">—— <small class="text-label-md">mmol/L</small></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<span class="text-headline-md font-bold">05-25</span>
|
||||||
|
<span class="text-body-md text-outline">周一</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
|
||||||
|
<span class="text-body-md text-outline">空腹</span>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-headline-lg font-extrabold text-error">10.2</span>
|
||||||
|
<span class="text-label-md text-outline ml-1">mmol/L</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Reward Section: 稳糖乐园 -->
|
||||||
|
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow overflow-hidden relative" id="sugar-paradise">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-headline-md font-headline-md text-on-surface">稳糖乐园</h3>
|
||||||
|
<p class="text-body-md text-outline">用健康打卡浇灌你的稳糖树</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-primary/10 rounded-xl px-3 py-1 text-center">
|
||||||
|
<span class="block text-headline-lg font-extrabold text-primary" id="total-points">110</span>
|
||||||
|
<span class="text-[10px] text-primary/70 font-bold">稳糖积分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mini Quick Actions -->
|
||||||
|
<div class="grid grid-cols-4 gap-2 mb-8">
|
||||||
|
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
|
||||||
|
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
|
||||||
|
<span class="text-label-md text-on-surface">血糖</span>
|
||||||
|
</button>
|
||||||
|
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
|
||||||
|
<span class="material-symbols-outlined text-primary">monitoring</span>
|
||||||
|
<span class="text-label-md text-on-surface">血压</span>
|
||||||
|
</button>
|
||||||
|
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
|
||||||
|
<span class="material-symbols-outlined text-primary">restaurant</span>
|
||||||
|
<span class="text-label-md text-on-surface">饮食</span>
|
||||||
|
</button>
|
||||||
|
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
|
||||||
|
<span class="material-symbols-outlined text-primary">exercise</span>
|
||||||
|
<span class="text-label-md text-on-surface">运动</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-label-md font-bold text-on-surface mb-3">今日控糖任务</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full border-2 border-primary flex items-center justify-center">
|
||||||
|
<span class="material-symbols-outlined text-[16px] text-primary" style="font-variation-settings: 'wght' 700;">check</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-body-md text-on-surface">测血糖血压</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-label-md text-primary font-bold">+10积分</span>
|
||||||
|
<span class="text-outline text-label-md font-bold italic">已完成</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full border-2 border-outline-variant/30 flex items-center justify-center">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-outline-variant"></span>
|
||||||
|
</div>
|
||||||
|
<span class="text-body-md text-on-surface">记今日饮食</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-label-md text-primary font-bold">+10积分</span>
|
||||||
|
<button class="bg-on-surface text-surface rounded-full px-4 py-1 text-label-md font-bold active-scale transition-all">去记录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Optimized Level System & Gamification -->
|
||||||
|
<div class="mt-8 bg-gradient-to-br from-primary/5 to-primary/20 rounded-3xl p-6 border border-primary/10 relative overflow-hidden">
|
||||||
|
<!-- Level Indicator Header -->
|
||||||
|
<div class="flex justify-between items-end mb-6">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="bg-primary text-on-primary text-[10px] px-2 py-0.5 rounded-full font-extrabold" id="level-label">Lv.4</span>
|
||||||
|
<span class="text-body-lg font-extrabold text-primary" id="stage-name">枝繁叶茂</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-primary/60">还差 <span class="font-bold">15 XP</span> 升级至 Lv.4</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-label-md font-bold text-primary" id="xp-text">5/50 XP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="w-full h-2.5 bg-white/50 rounded-full mb-8 overflow-hidden backdrop-blur-sm border border-white/20">
|
||||||
|
<div class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full transition-all duration-500 shadow-inner" id="xp-progress" style="width: 10%;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Plant Growth Display -->
|
||||||
|
<div class="flex items-center justify-center py-8 relative min-h-[160px]">
|
||||||
|
<!-- Watering Animation Container -->
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full pointer-events-none z-10" id="rain-container"></div>
|
||||||
|
<!-- Visual Stage Indicator Background -->
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
|
||||||
|
<span class="text-[120px] material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">eco</span>
|
||||||
|
</div>
|
||||||
|
<!-- Current Plant State -->
|
||||||
|
<div class="relative z-20 transition-all duration-300" id="plant-container">
|
||||||
|
<span class="text-7xl drop-shadow-2xl filter block" id="plant-emoji">🪴</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Watering Action -->
|
||||||
|
<div class="relative z-30">
|
||||||
|
<button class="w-full bg-primary text-on-primary py-4 rounded-2xl text-body-lg font-bold flex items-center justify-center gap-3 shadow-lg active:scale-95 transition-all active:bg-primary-container group" id="water-btn">
|
||||||
|
<span class="material-symbols-outlined text-[24px] transition-transform group-active:-rotate-12 group-hover:scale-110">opacity</span>
|
||||||
|
给小树浇水
|
||||||
|
</button>
|
||||||
|
<p class="text-[11px] text-center text-outline mt-3 italic">浇水可消耗 5 积分,获得 10 XP</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Social Interaction -->
|
||||||
|
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-tertiary-container/20 rounded-2xl flex items-center justify-center">
|
||||||
|
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">favorite</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-body-md font-bold text-on-surface">邀请家人点赞</h4>
|
||||||
|
<p class="text-[12px] text-outline">分享战报给家人,邀请他们为您点赞鼓励</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-outline">chevron_right</span>
|
||||||
|
</section>
|
||||||
|
<!-- Status Tip -->
|
||||||
|
<div class="flex items-start gap-3 px-2">
|
||||||
|
<span class="material-symbols-outlined text-primary-container text-[20px]">chat_bubble</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-body-md text-on-surface">先完成血糖记录,<span class="text-primary font-bold">今日第一格就亮起来</span>。</p>
|
||||||
|
<button class="text-outline text-label-md hover:text-primary transition-colors">轻触换一句鼓励 ›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Care List Horizontal -->
|
||||||
|
<div class="flex items-center gap-3 overflow-x-auto hide-scrollbar -mx-container-margin px-container-margin pb-2">
|
||||||
|
<div class="shrink-0 flex items-center gap-2 bg-white px-3 py-2 rounded-xl shadow-sm border border-outline-variant/20">
|
||||||
|
<span class="text-label-md text-outline">就诊卡</span>
|
||||||
|
<span class="text-label-md font-extrabold text-on-surface">10 张</span>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 flex items-center gap-2 bg-primary text-on-primary px-3 py-2 rounded-xl shadow-md">
|
||||||
|
<span class="text-label-md font-bold">吕帅</span>
|
||||||
|
<span class="text-[10px] bg-white/20 rounded px-1">23岁</span>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 flex items-center gap-2 bg-surface-container-high text-on-surface-variant px-3 py-2 rounded-xl border border-outline-variant/30">
|
||||||
|
<span class="text-label-md font-bold">霍爱玲</span>
|
||||||
|
<span class="text-[10px] text-outline">57岁</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!-- Footer Note -->
|
||||||
|
<footer class="mt-8 text-center px-container-margin pb-12">
|
||||||
|
<p class="text-[11px] text-outline-variant/60">数据每日同步 · 阈值仅作参考,请遵医嘱</p>
|
||||||
|
</footer>
|
||||||
|
<!-- Navigation Bar -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// State Management
|
||||||
|
let currentXP = 35;
|
||||||
|
let currentLevel = 3;
|
||||||
|
let totalPoints = 120;
|
||||||
|
const maxLevel = 10;
|
||||||
|
const xpPerLevel = 50;
|
||||||
|
|
||||||
|
const stageNames = [
|
||||||
|
"种子眠", "破土而出", "茁壮成长", "枝繁叶茂", "初绽花蕾",
|
||||||
|
"繁花似锦", "硕果初步", "丰收在望", "参天大树", "森林守护"
|
||||||
|
];
|
||||||
|
|
||||||
|
const plantEmojis = [
|
||||||
|
"🫘", "🌱", "🌿", "🪴", "🎍",
|
||||||
|
"🌸", "🍒", "🍎", "🌳", "🌲"
|
||||||
|
];
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const waterBtn = document.getElementById('water-btn');
|
||||||
|
const rainContainer = document.getElementById('rain-container');
|
||||||
|
const plantContainer = document.getElementById('plant-container');
|
||||||
|
const plantEmoji = document.getElementById('plant-emoji');
|
||||||
|
const xpProgress = document.getElementById('xp-progress');
|
||||||
|
const xpText = document.getElementById('xp-text');
|
||||||
|
const levelLabel = document.getElementById('level-label');
|
||||||
|
const stageNameLabel = document.getElementById('stage-name');
|
||||||
|
const totalPointsLabel = document.getElementById('total-points');
|
||||||
|
|
||||||
|
// Watering Animation Logic
|
||||||
|
function createRain() {
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const droplet = document.createElement('div');
|
||||||
|
droplet.className = 'droplet';
|
||||||
|
droplet.style.left = `${Math.random() * 100}%`;
|
||||||
|
droplet.style.animationDelay = `${Math.random() * 0.4}s`;
|
||||||
|
rainContainer.appendChild(droplet);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
setTimeout(() => droplet.remove(), 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerGrowthPulse() {
|
||||||
|
plantContainer.classList.add('growth-pulse');
|
||||||
|
setTimeout(() => {
|
||||||
|
plantContainer.classList.remove('growth-pulse');
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSparkles() {
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const sparkle = document.createElement('span');
|
||||||
|
sparkle.className = 'material-symbols-outlined shimmer-particle';
|
||||||
|
sparkle.textContent = 'sparkles';
|
||||||
|
sparkle.style.left = `${40 + (Math.random() - 0.5) * 40}%`;
|
||||||
|
sparkle.style.top = `${40 + (Math.random() - 0.5) * 40}%`;
|
||||||
|
sparkle.style.animationDuration = `${0.6 + Math.random() * 0.4}s`;
|
||||||
|
plantContainer.appendChild(sparkle);
|
||||||
|
|
||||||
|
setTimeout(() => sparkle.remove(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSugarDecreaseEffect() {
|
||||||
|
// 1. Floating Text
|
||||||
|
const floatingText = document.createElement('div');
|
||||||
|
floatingText.className = 'sugar-decrease-text';
|
||||||
|
floatingText.textContent = '-0.5 mmol/L';
|
||||||
|
|
||||||
|
// Randomize spawn position near the plant
|
||||||
|
const rect = plantContainer.getBoundingClientRect();
|
||||||
|
floatingText.style.left = `${rect.left + rect.width / 2}px`;
|
||||||
|
floatingText.style.top = `${rect.top}px`;
|
||||||
|
|
||||||
|
document.body.appendChild(floatingText);
|
||||||
|
setTimeout(() => floatingText.remove(), 2000);
|
||||||
|
|
||||||
|
// 2. Full-screen Downward Particles
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const particle = document.createElement('div');
|
||||||
|
particle.className = 'sugar-particle text-primary-container';
|
||||||
|
// Mix arrows and symbols
|
||||||
|
particle.innerHTML = Math.random() > 0.5 ? '<span class="material-symbols-outlined">south</span>' : '▼';
|
||||||
|
particle.style.left = `${Math.random() * 100}vw`;
|
||||||
|
particle.style.fontSize = `${16 + Math.random() * 16}px`;
|
||||||
|
particle.style.opacity = '0';
|
||||||
|
|
||||||
|
document.body.appendChild(particle);
|
||||||
|
setTimeout(() => particle.remove(), 1500);
|
||||||
|
}, i * 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisplay() {
|
||||||
|
const progress = (currentXP / xpPerLevel) * 100;
|
||||||
|
xpProgress.style.width = `${progress}%`;
|
||||||
|
xpText.textContent = `${currentXP}/${xpPerLevel} XP`;
|
||||||
|
levelLabel.textContent = `Lv.${currentLevel}`;
|
||||||
|
stageNameLabel.textContent = stageNames[currentLevel - 1];
|
||||||
|
plantEmoji.textContent = plantEmojis[currentLevel - 1];
|
||||||
|
totalPointsLabel.textContent = totalPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Logic
|
||||||
|
waterBtn.addEventListener('click', () => {
|
||||||
|
if (totalPoints < 5) {
|
||||||
|
alert("积分不足,快去完成任务领积分吧!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock button briefly
|
||||||
|
waterBtn.disabled = true;
|
||||||
|
waterBtn.classList.add('opacity-50');
|
||||||
|
|
||||||
|
// 1. Consume Points
|
||||||
|
totalPoints -= 5;
|
||||||
|
|
||||||
|
// 2. Trigger Animations
|
||||||
|
createRain();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
triggerGrowthPulse();
|
||||||
|
createSparkles();
|
||||||
|
triggerSugarDecreaseEffect(); // New Effect
|
||||||
|
|
||||||
|
// 3. Update State
|
||||||
|
currentXP += 10;
|
||||||
|
if (currentXP >= xpPerLevel && currentLevel < maxLevel) {
|
||||||
|
currentXP -= xpPerLevel;
|
||||||
|
currentLevel++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDisplay();
|
||||||
|
|
||||||
|
// Unlock button
|
||||||
|
waterBtn.disabled = false;
|
||||||
|
waterBtn.classList.remove('opacity-50');
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
updateDisplay();
|
||||||
|
|
||||||
|
// Simple button micro-interactions for others
|
||||||
|
document.querySelectorAll('button:not(#water-btn)').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if(!btn.classList.contains('cursor-not-allowed')) {
|
||||||
|
// Interaction logic
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 识糖小课堂平台接入说明
|
||||||
|
|
||||||
|
## 已接入能力
|
||||||
|
|
||||||
|
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
|
||||||
|
- 按周一日期自动分配最多 7 人的混合同行组,前期不区分男女。
|
||||||
|
- 本周已经进入旧男女组的账号,下次读取榜单时保留成绩并自动迁入混合组。
|
||||||
|
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
|
||||||
|
- 以真实平台昵称、头像、认糖数和本周最高分排序。
|
||||||
|
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
|
||||||
|
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
|
||||||
|
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
|
||||||
|
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
|
||||||
|
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key`、`learned_count`、`score`、`ended`。
|
||||||
|
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
|
||||||
|
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`。
|
||||||
|
|
||||||
|
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`。
|
||||||
|
|
||||||
|
## 部署顺序
|
||||||
|
|
||||||
|
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`。
|
||||||
|
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php` 和 `TcmController.php`。
|
||||||
|
3. 重新构建并上传小程序前端。
|
||||||
|
|
||||||
|
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
|
||||||
|
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
|
||||||
|
|
||||||
|
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
|
||||||
|
|
||||||
|
## 上线前检查
|
||||||
|
|
||||||
|
- 用不同账号进入,确认按进入顺序加入同一个最多 7 人的混合同行组。
|
||||||
|
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
|
||||||
|
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
|
||||||
|
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
|
||||||
|
- 周一验证新周重新分组,旧周成绩不带入新周。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 识糖小课堂独立功能包
|
||||||
|
|
||||||
|
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
|
||||||
|
|
||||||
|
## 目录内容
|
||||||
|
|
||||||
|
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
|
||||||
|
- `game-endless.scss`:完整页面与动画样式。
|
||||||
|
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
|
||||||
|
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
|
||||||
|
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
|
||||||
|
|
||||||
|
## 迁移步骤
|
||||||
|
|
||||||
|
1. 复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录,并确保目标项目同时提供 `tongji/components/TongjiIcon.vue` 与 `tongji/utils/svgDataUrl.js`。
|
||||||
|
2. 在目标项目 `pages.json` 的 `tongji` 分包 `pages` 数组中加入:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "endless-game/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "识糖小课堂",
|
||||||
|
"backgroundColor": "#eefbf4",
|
||||||
|
"disableScroll": false,
|
||||||
|
"enableShareAppMessage": true,
|
||||||
|
"enableShareTimeline": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 使用 `/tongji/endless-game/index` 打开游戏。
|
||||||
|
4. 微信小程序后台需要将 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com` 配置为合法的音频与图片下载域名。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
|
||||||
|
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
|
||||||
|
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
|
||||||
|
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
|
||||||
|
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`。
|
||||||
|
- 本功能包依赖 uni-app Vue 3、`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
|
||||||
|
- 食物风险等级和科普文案集中在 `index.vue` 的 `FOODS` 配置中,正式上线前仍需由医院医生审核。
|
||||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,577 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// 识糖小课堂棋盘压力测试:复刻生产版的 5x4 棋盘、掉落、洗牌和渐进换组规则。
|
||||||
|
// 运行:node tongji/endless-game/build/stress-game.mjs --runs 10000
|
||||||
|
|
||||||
|
const ROWS = 5
|
||||||
|
const COLS = 4
|
||||||
|
const HIGH_CAP = 3
|
||||||
|
const MAX_CASCADE_WAVES = 3
|
||||||
|
const DANGER_DROP_CHANCE = 0.1
|
||||||
|
const COLUMN_GROUP_BIAS = 0.72
|
||||||
|
|
||||||
|
const FOOD_GROUPS = [
|
||||||
|
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
|
||||||
|
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
|
||||||
|
{ title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' },
|
||||||
|
{ title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' },
|
||||||
|
{ title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
|
||||||
|
{ title: '下午茶甜饮', safe: ['milk', 'tea', 'apple', 'strawberry'], danger: 'milkTea' },
|
||||||
|
{ title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
|
||||||
|
{ title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
|
||||||
|
{ title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const HIGH_KEYS = new Set(FOOD_GROUPS.map(group => group.danger))
|
||||||
|
const SAFE_KEYS = new Set(FOOD_GROUPS.flatMap(group => group.safe))
|
||||||
|
|
||||||
|
function readNumberArg(name, fallback) {
|
||||||
|
const index = process.argv.indexOf(name)
|
||||||
|
if (index < 0) return fallback
|
||||||
|
const value = Number(process.argv[index + 1])
|
||||||
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUNS = readNumberArg('--runs', 10000)
|
||||||
|
const TURNS = readNumberArg('--turns', 80)
|
||||||
|
const BASE_SEED = readNumberArg('--seed', 20260722)
|
||||||
|
|
||||||
|
function createRng(seed) {
|
||||||
|
let state = seed >>> 0 || 1
|
||||||
|
return () => {
|
||||||
|
state ^= state << 13
|
||||||
|
state ^= state >>> 17
|
||||||
|
state ^= state << 5
|
||||||
|
return (state >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneBoard(source) {
|
||||||
|
return source.map(tile => tile ? { ...tile } : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tile(key) {
|
||||||
|
return { key }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdjacent(a, b) {
|
||||||
|
const ar = Math.floor(a / COLS)
|
||||||
|
const ac = a % COLS
|
||||||
|
const br = Math.floor(b / COLS)
|
||||||
|
const bc = b % COLS
|
||||||
|
return Math.abs(ar - br) + Math.abs(ac - bc) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function swap(source, a, b) {
|
||||||
|
const next = cloneBoard(source)
|
||||||
|
;[next[a], next[b]] = [next[b], next[a]]
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle(source, rng) {
|
||||||
|
const result = cloneBoard(source)
|
||||||
|
for (let i = result.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(rng() * (i + 1))
|
||||||
|
;[result[i], result[j]] = [result[j], result[i]]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatches(source) {
|
||||||
|
const groups = []
|
||||||
|
for (let row = 0; row < ROWS; row += 1) {
|
||||||
|
let start = 0
|
||||||
|
while (start < COLS) {
|
||||||
|
let end = start + 1
|
||||||
|
while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1
|
||||||
|
if (source[row * COLS + start] && end - start >= 3) {
|
||||||
|
groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) })
|
||||||
|
}
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let col = 0; col < COLS; col += 1) {
|
||||||
|
let start = 0
|
||||||
|
while (start < ROWS) {
|
||||||
|
let end = start + 1
|
||||||
|
while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1
|
||||||
|
if (source[start * COLS + col] && end - start >= 3) {
|
||||||
|
groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) })
|
||||||
|
}
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumerateMatchingMoves(source) {
|
||||||
|
const moves = []
|
||||||
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
|
const candidates = []
|
||||||
|
if (i % COLS < COLS - 1) candidates.push(i + 1)
|
||||||
|
if (i + COLS < source.length) candidates.push(i + COLS)
|
||||||
|
for (const j of candidates) {
|
||||||
|
const copy = swap(source, i, j)
|
||||||
|
const groups = findMatches(copy)
|
||||||
|
if (!groups.length) continue
|
||||||
|
const indices = new Set(groups.flatMap(group => group.indices))
|
||||||
|
const hitsHigh = [...indices].some(index => HIGH_KEYS.has(copy[index]?.key))
|
||||||
|
moves.push({ from: i, to: j, hitsHigh, groups })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeMoves(source) {
|
||||||
|
return enumerateMatchingMoves(source).filter(move => !move.hitsHigh)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wouldCreateMatchAt(source, index, key) {
|
||||||
|
const row = Math.floor(index / COLS)
|
||||||
|
const col = index % COLS
|
||||||
|
const keyAt = target => target === index ? key : source[target]?.key
|
||||||
|
let horizontal = 1
|
||||||
|
for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1
|
||||||
|
for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1
|
||||||
|
if (horizontal >= 3) return true
|
||||||
|
let vertical = 1
|
||||||
|
for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1
|
||||||
|
for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1
|
||||||
|
return vertical >= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function openingKeys(state) {
|
||||||
|
const [a, b, c, d] = state.activeSafeKeys
|
||||||
|
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||||
|
return [
|
||||||
|
a, b, c, d,
|
||||||
|
b, c, d, a,
|
||||||
|
c, d, a, danger,
|
||||||
|
d, a, danger, b,
|
||||||
|
a, b, c, d
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function forcedPlayableKeys(state) {
|
||||||
|
const [a, b, c, d] = state.activeSafeKeys
|
||||||
|
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||||
|
return [
|
||||||
|
d, a, danger, c,
|
||||||
|
d, c, b, danger,
|
||||||
|
c, a, b, a,
|
||||||
|
d, d, a, a,
|
||||||
|
b, d, c, b
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlayableBoard(state, keys = openingKeys(state)) {
|
||||||
|
let candidate = keys.map(tile)
|
||||||
|
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||||||
|
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
|
||||||
|
candidate = shuffle(candidate, state.rng)
|
||||||
|
}
|
||||||
|
state.metrics.guaranteedFallbacks += 1
|
||||||
|
return forcedPlayableKeys(state).map(tile)
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStablePlayableBoard(state, source) {
|
||||||
|
const tiles = source.filter(Boolean).map(item => ({ ...item }))
|
||||||
|
if (tiles.length === ROWS * COLS) {
|
||||||
|
for (let attempt = 0; attempt < 300; attempt += 1) {
|
||||||
|
const candidate = shuffle(tiles, state.rng)
|
||||||
|
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.metrics.stableFallbacks += 1
|
||||||
|
return buildPlayableBoard(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueSafeGroupTransition(state, keys) {
|
||||||
|
const target = [...keys]
|
||||||
|
const last = state.queuedSafeGroups[state.queuedSafeGroups.length - 1]
|
||||||
|
if (!last || !last.every((key, index) => key === target[index])) {
|
||||||
|
state.queuedSafeGroups.push(target)
|
||||||
|
}
|
||||||
|
startNextTransition(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNextTransition(state) {
|
||||||
|
if (state.transition) return false
|
||||||
|
while (state.queuedSafeGroups.length) {
|
||||||
|
const target = state.queuedSafeGroups[0]
|
||||||
|
const slot = target.findIndex((key, index) => state.activeSafeKeys[index] !== key)
|
||||||
|
if (slot < 0) {
|
||||||
|
state.queuedSafeGroups.shift()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const from = state.activeSafeKeys[slot]
|
||||||
|
const to = target[slot]
|
||||||
|
state.activeSafeKeys[slot] = to
|
||||||
|
state.transition = { from, to, slot, age: 0 }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishTransitionIfReady(state) {
|
||||||
|
let changed = false
|
||||||
|
while (state.transition) {
|
||||||
|
const { from, to } = state.transition
|
||||||
|
const remaining = state.board.filter(item => item?.key === from).length
|
||||||
|
if (remaining > 2) break
|
||||||
|
if (remaining) {
|
||||||
|
state.board = state.board.map(item => item?.key === from ? { ...item, key: to } : item)
|
||||||
|
state.metrics.tailConversions += remaining
|
||||||
|
}
|
||||||
|
state.transition = null
|
||||||
|
changed = true
|
||||||
|
startNextTransition(state)
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareTransitionForReshuffle(state) {
|
||||||
|
const activeSet = new Set(state.activeSafeKeys)
|
||||||
|
const counts = Object.fromEntries(state.activeSafeKeys.map(key => [key, 0]))
|
||||||
|
state.board.forEach(item => {
|
||||||
|
if (item && activeSet.has(item.key)) counts[item.key] += 1
|
||||||
|
})
|
||||||
|
let imported = 0
|
||||||
|
const next = state.board.map(item => {
|
||||||
|
if (!item || item.key === 'camel' || HIGH_KEYS.has(item.key)) return item ? { ...item } : item
|
||||||
|
if (state.transition && item.key === state.transition.from) {
|
||||||
|
imported += 1
|
||||||
|
counts[state.transition.to] += 1
|
||||||
|
return { ...item, key: state.transition.to }
|
||||||
|
}
|
||||||
|
if (activeSet.has(item.key)) return { ...item }
|
||||||
|
const key = [...state.activeSafeKeys].sort((a, b) => counts[a] - counts[b])[0]
|
||||||
|
counts[key] += 1
|
||||||
|
imported += 1
|
||||||
|
return { ...item, key }
|
||||||
|
})
|
||||||
|
state.board = next
|
||||||
|
state.metrics.reshuffleImports += imported
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomFoodKey(state, snapshot, col) {
|
||||||
|
const highs = snapshot.filter(item => item && HIGH_KEYS.has(item.key)).length
|
||||||
|
if (highs < HIGH_CAP && state.rng() < DANGER_DROP_CHANCE) {
|
||||||
|
return FOOD_GROUPS[state.themeIndex].danger
|
||||||
|
}
|
||||||
|
if (state.rng() < COLUMN_GROUP_BIAS) return state.activeSafeKeys[col % state.activeSafeKeys.length]
|
||||||
|
return state.activeSafeKeys[Math.floor(state.rng() * state.activeSafeKeys.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDropKey(state, snapshot, index, col) {
|
||||||
|
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||||
|
const key = randomFoodKey(state, snapshot, col)
|
||||||
|
if (!wouldCreateMatchAt(snapshot, index, key)) return key
|
||||||
|
}
|
||||||
|
const fallback = [...state.activeSafeKeys, FOOD_GROUPS[state.themeIndex].danger]
|
||||||
|
.find(key => !wouldCreateMatchAt(snapshot, index, key))
|
||||||
|
return fallback || state.activeSafeKeys[(index + col) % state.activeSafeKeys.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAndFill(state) {
|
||||||
|
const next = Array(ROWS * COLS).fill(null)
|
||||||
|
for (let col = 0; col < COLS; col += 1) {
|
||||||
|
const existing = []
|
||||||
|
for (let row = ROWS - 1; row >= 0; row -= 1) {
|
||||||
|
const item = state.board[row * COLS + col]
|
||||||
|
if (item) existing.push(item)
|
||||||
|
}
|
||||||
|
existing.forEach((item, offset) => {
|
||||||
|
next[(ROWS - 1 - offset) * COLS + col] = item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (let row = 0; row < ROWS; row += 1) {
|
||||||
|
for (let col = 0; col < COLS; col += 1) {
|
||||||
|
const index = row * COLS + col
|
||||||
|
if (!next[index]) next[index] = tile(pickDropKey(state, next, index, col))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.board = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function reshuffle(state) {
|
||||||
|
prepareTransitionForReshuffle(state)
|
||||||
|
let candidate = null
|
||||||
|
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||||||
|
const shuffled = shuffle(state.board, state.rng)
|
||||||
|
if (findMatches(shuffled).length || !safeMoves(shuffled).length) continue
|
||||||
|
candidate = shuffled
|
||||||
|
break
|
||||||
|
}
|
||||||
|
state.board = candidate || buildPlayableBoard(state, forcedPlayableKeys(state))
|
||||||
|
finishTransitionIfReady(state)
|
||||||
|
state.metrics.reshuffles += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceTheme(state) {
|
||||||
|
const previous = FOOD_GROUPS[state.themeIndex]
|
||||||
|
state.themeIndex = (state.themeIndex + 1) % FOOD_GROUPS.length
|
||||||
|
const next = FOOD_GROUPS[state.themeIndex]
|
||||||
|
state.board = state.board.map(item => item?.key === previous.danger ? { ...item, key: next.danger } : item)
|
||||||
|
queueSafeGroupTransition(state, next.safe)
|
||||||
|
state.metrics.themeChanges += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMatches(state, initialGroups) {
|
||||||
|
let groups = initialGroups
|
||||||
|
let wave = 0
|
||||||
|
let camelMatch = 0
|
||||||
|
while (groups.length && wave < MAX_CASCADE_WAVES) {
|
||||||
|
wave += 1
|
||||||
|
const remove = new Set(groups.flatMap(group => group.indices))
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.indices.every(index => state.board[index]?.key === 'camel')) {
|
||||||
|
camelMatch = Math.max(camelMatch, group.indices.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove.forEach(index => { state.board[index] = null })
|
||||||
|
collapseAndFill(state)
|
||||||
|
finishTransitionIfReady(state)
|
||||||
|
groups = findMatches(state.board)
|
||||||
|
const autoHigh = groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))
|
||||||
|
if (autoHigh) {
|
||||||
|
state.metrics.automaticHighAvoided += 1
|
||||||
|
state.board = makeStablePlayableBoard(state, state.board)
|
||||||
|
groups = []
|
||||||
|
} else if (wave >= MAX_CASCADE_WAVES && groups.length) {
|
||||||
|
state.metrics.cascadeCaps += 1
|
||||||
|
state.board = makeStablePlayableBoard(state, state.board)
|
||||||
|
groups = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.metrics.maxCascade = Math.max(state.metrics.maxCascade, wave)
|
||||||
|
if (camelMatch >= 3) advanceTheme(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCamelTool(state) {
|
||||||
|
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||||
|
const targets = state.board
|
||||||
|
.map((item, index) => item?.key === danger ? index : -1)
|
||||||
|
.filter(index => index >= 0)
|
||||||
|
if (!targets.length) return false
|
||||||
|
const index = targets[Math.floor(state.rng() * targets.length)]
|
||||||
|
state.board[index] = tile('camel')
|
||||||
|
state.metrics.toolUses += 1
|
||||||
|
const groups = findMatches(state.board)
|
||||||
|
if (groups.length) resolveMatches(state, groups)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailure(state, type, detail = '') {
|
||||||
|
state.metrics.failures[type] = (state.metrics.failures[type] || 0) + 1
|
||||||
|
if (state.metrics.samples.length < 20) {
|
||||||
|
state.metrics.samples.push({ run: state.run, turn: state.turn, type, detail, board: state.board.map(item => item?.key || '-') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateState(state, expectStable = true) {
|
||||||
|
if (state.board.length !== ROWS * COLS || state.board.some(item => !item?.key)) {
|
||||||
|
recordFailure(state, 'invalid_board_size')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const currentDanger = FOOD_GROUPS[state.themeIndex].danger
|
||||||
|
const highKeys = state.board.filter(item => HIGH_KEYS.has(item.key)).map(item => item.key)
|
||||||
|
if (highKeys.some(key => key !== currentDanger)) recordFailure(state, 'wrong_theme_danger', highKeys.join(','))
|
||||||
|
if (highKeys.length > HIGH_CAP) recordFailure(state, 'danger_cap_exceeded', String(highKeys.length))
|
||||||
|
|
||||||
|
const allowed = new Set([...state.activeSafeKeys, 'camel'])
|
||||||
|
if (state.transition) allowed.add(state.transition.from)
|
||||||
|
const stale = state.board.filter(item => !HIGH_KEYS.has(item.key) && !allowed.has(item.key)).map(item => item.key)
|
||||||
|
if (stale.length) recordFailure(state, 'stale_food', stale.join(','))
|
||||||
|
|
||||||
|
const normalTypes = new Set(state.board.filter(item => SAFE_KEYS.has(item.key)).map(item => item.key))
|
||||||
|
if (normalTypes.size > 5) recordFailure(state, 'too_many_normal_types', String(normalTypes.size))
|
||||||
|
if (new Set(state.activeSafeKeys).size !== 4) recordFailure(state, 'duplicate_active_food')
|
||||||
|
if (state.transition) {
|
||||||
|
const remaining = state.board.filter(item => item.key === state.transition.from).length
|
||||||
|
if (remaining <= 2) recordFailure(state, 'orphan_transition_tail', `${state.transition.from}:${remaining}`)
|
||||||
|
}
|
||||||
|
if (expectStable && findMatches(state.board).length) recordFailure(state, 'unresolved_match')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMetrics() {
|
||||||
|
return {
|
||||||
|
runs: RUNS,
|
||||||
|
turnsPerRun: TURNS,
|
||||||
|
totalTurns: 0,
|
||||||
|
validMoves: 0,
|
||||||
|
reshuffles: 0,
|
||||||
|
reshuffleImports: 0,
|
||||||
|
guaranteedFallbacks: 0,
|
||||||
|
stableFallbacks: 0,
|
||||||
|
themeChanges: 0,
|
||||||
|
toolUses: 0,
|
||||||
|
tailConversions: 0,
|
||||||
|
automaticHighAvoided: 0,
|
||||||
|
cascadeCaps: 0,
|
||||||
|
maxCascade: 0,
|
||||||
|
unsafeHighMoveProbes: 0,
|
||||||
|
maxTransitionAge: 0,
|
||||||
|
maxQueuedThemes: 0,
|
||||||
|
transitionDrainTurns: 0,
|
||||||
|
unfinishedTransitions: 0,
|
||||||
|
failures: {},
|
||||||
|
samples: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTargetedTailTests(metrics) {
|
||||||
|
for (let groupIndex = 0; groupIndex < FOOD_GROUPS.length; groupIndex += 1) {
|
||||||
|
const nextIndex = (groupIndex + 1) % FOOD_GROUPS.length
|
||||||
|
const previousGroup = FOOD_GROUPS[groupIndex]
|
||||||
|
const nextGroup = FOOD_GROUPS[nextIndex]
|
||||||
|
// 相邻主题允许在相同槽位保留同一种食物。定向测试应选择真正退出下一组的食物,
|
||||||
|
// 不能固定测试第 0 槽,否则“牛奶→牛奶”会被误报为尾巴未转换。
|
||||||
|
const slot = previousGroup.safe.findIndex(key => !nextGroup.safe.includes(key))
|
||||||
|
if (slot < 0) continue
|
||||||
|
for (const remaining of [0, 1, 2]) {
|
||||||
|
const state = {
|
||||||
|
run: -1,
|
||||||
|
turn: remaining,
|
||||||
|
rng: createRng(BASE_SEED + groupIndex * 17 + remaining),
|
||||||
|
metrics,
|
||||||
|
themeIndex: nextIndex,
|
||||||
|
activeSafeKeys: [...nextGroup.safe],
|
||||||
|
transition: {
|
||||||
|
from: previousGroup.safe[slot],
|
||||||
|
to: nextGroup.safe[slot],
|
||||||
|
slot,
|
||||||
|
age: 0
|
||||||
|
},
|
||||||
|
queuedSafeGroups: [[...nextGroup.safe]],
|
||||||
|
board: []
|
||||||
|
}
|
||||||
|
const fill = nextGroup.safe
|
||||||
|
state.board = Array.from({ length: ROWS * COLS }, (_, index) => tile(fill[index % fill.length]))
|
||||||
|
for (let index = 0; index < remaining; index += 1) state.board[index] = tile(state.transition.from)
|
||||||
|
finishTransitionIfReady(state)
|
||||||
|
if (state.board.some(item => item.key === state.transition?.from || item.key === previousGroup.safe[slot])) {
|
||||||
|
recordFailure(state, 'targeted_tail_not_converted', `${groupIndex}:${remaining}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = createMetrics()
|
||||||
|
runTargetedTailTests(metrics)
|
||||||
|
|
||||||
|
for (let run = 0; run < RUNS; run += 1) {
|
||||||
|
const state = {
|
||||||
|
run,
|
||||||
|
turn: 0,
|
||||||
|
rng: createRng(BASE_SEED + run * 2654435761),
|
||||||
|
metrics,
|
||||||
|
themeIndex: run % FOOD_GROUPS.length,
|
||||||
|
activeSafeKeys: [...FOOD_GROUPS[run % FOOD_GROUPS.length].safe],
|
||||||
|
transition: null,
|
||||||
|
queuedSafeGroups: [],
|
||||||
|
board: []
|
||||||
|
}
|
||||||
|
state.board = buildPlayableBoard(state)
|
||||||
|
validateState(state)
|
||||||
|
|
||||||
|
let toolsUsed = 0
|
||||||
|
for (let turn = 0; turn < TURNS; turn += 1) {
|
||||||
|
state.turn = turn
|
||||||
|
metrics.totalTurns += 1
|
||||||
|
if (state.transition) {
|
||||||
|
state.transition.age += 1
|
||||||
|
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
|
||||||
|
}
|
||||||
|
metrics.maxQueuedThemes = Math.max(metrics.maxQueuedThemes, state.queuedSafeGroups.length)
|
||||||
|
|
||||||
|
// 模拟玩家积攒到驼乳后替换高糖,但每局限制5次,避免测试人为填满棋盘。
|
||||||
|
if (toolsUsed < 5 && turn > 0 && turn % 13 === 0 && useCamelTool(state)) {
|
||||||
|
toolsUsed += 1
|
||||||
|
finishTransitionIfReady(state)
|
||||||
|
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||||
|
}
|
||||||
|
|
||||||
|
let moves = safeMoves(state.board)
|
||||||
|
if (!moves.length) {
|
||||||
|
reshuffle(state)
|
||||||
|
moves = safeMoves(state.board)
|
||||||
|
}
|
||||||
|
if (!moves.length) {
|
||||||
|
recordFailure(state, 'dead_board_after_reshuffle')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 额外探测“主动三消高糖会结束”的判定,不改变主模拟棋盘。
|
||||||
|
const unsafe = enumerateMatchingMoves(state.board).find(move => move.hitsHigh)
|
||||||
|
if (unsafe) {
|
||||||
|
const probe = swap(state.board, unsafe.from, unsafe.to)
|
||||||
|
const direct = findMatches(probe)
|
||||||
|
if (!direct.some(group => group.indices.some(index => HIGH_KEYS.has(probe[index]?.key)))) {
|
||||||
|
recordFailure(state, 'unsafe_high_probe_missed')
|
||||||
|
}
|
||||||
|
metrics.unsafeHighMoveProbes += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = moves[Math.floor(state.rng() * moves.length)]
|
||||||
|
state.board = swap(state.board, move.from, move.to)
|
||||||
|
const groups = findMatches(state.board)
|
||||||
|
if (!groups.length) {
|
||||||
|
recordFailure(state, 'safe_move_without_match')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))) {
|
||||||
|
recordFailure(state, 'safe_move_hit_high')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
resolveMatches(state, groups)
|
||||||
|
metrics.validMoves += 1
|
||||||
|
|
||||||
|
// 用较快节奏覆盖连续换组和排队逻辑,强度高于正常玩家。
|
||||||
|
if ((turn + 1) % 16 === 0 && turn + 1 < TURNS) advanceTheme(state)
|
||||||
|
finishTransitionIfReady(state)
|
||||||
|
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||||
|
if (!safeMoves(state.board).length) reshuffle(state)
|
||||||
|
validateState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止新增主题后继续正常消除,验证排队中的旧食物能否全部退出,避免队列永久积压。
|
||||||
|
let drainTurn = 0
|
||||||
|
while ((state.transition || state.queuedSafeGroups.length) && drainTurn < 80) {
|
||||||
|
state.turn = TURNS + drainTurn
|
||||||
|
if (state.transition) {
|
||||||
|
state.transition.age += 1
|
||||||
|
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
|
||||||
|
}
|
||||||
|
let moves = safeMoves(state.board)
|
||||||
|
if (!moves.length) {
|
||||||
|
reshuffle(state)
|
||||||
|
moves = safeMoves(state.board)
|
||||||
|
}
|
||||||
|
if (!moves.length) {
|
||||||
|
recordFailure(state, 'dead_board_while_draining')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const move = moves[Math.floor(state.rng() * moves.length)]
|
||||||
|
state.board = swap(state.board, move.from, move.to)
|
||||||
|
resolveMatches(state, findMatches(state.board))
|
||||||
|
metrics.validMoves += 1
|
||||||
|
finishTransitionIfReady(state)
|
||||||
|
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||||
|
if (!safeMoves(state.board).length) reshuffle(state)
|
||||||
|
validateState(state)
|
||||||
|
drainTurn += 1
|
||||||
|
}
|
||||||
|
metrics.transitionDrainTurns += drainTurn
|
||||||
|
if (state.transition || state.queuedSafeGroups.length) metrics.unfinishedTransitions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureCount = Object.values(metrics.failures).reduce((sum, count) => sum + count, 0)
|
||||||
|
const result = {
|
||||||
|
ok: failureCount === 0,
|
||||||
|
...metrics,
|
||||||
|
failureCount
|
||||||
|
}
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
||||||
|
if (!result.ok) process.exitCode = 1
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
const FOOD_ICON_FILES = [
|
||||||
|
'tofu.jpg',
|
||||||
|
'spinach.jpg',
|
||||||
|
'carrot.jpg',
|
||||||
|
'fish.jpg',
|
||||||
|
'redBeanCongee.jpg',
|
||||||
|
'milkTea.jpg'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep endless-game food icons inside the tongji subpackage.
|
||||||
|
* Importing them from Vue would make Vite emit them into the main-package assets folder.
|
||||||
|
*/
|
||||||
|
export function tongjiFoodAssetsPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'tongji-food-assets',
|
||||||
|
apply: 'build',
|
||||||
|
generateBundle() {
|
||||||
|
FOOD_ICON_FILES.forEach((fileName) => {
|
||||||
|
this.emitFile({
|
||||||
|
type: 'asset',
|
||||||
|
fileName: `tongji/endless-game/assets/food/${fileName}`,
|
||||||
|
source: readFileSync(resolve(process.cwd(), 'tongji/endless-game/assets/food', fileName))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
|
||||||
|
*/
|
||||||
|
export function useGameAuth(proxy) {
|
||||||
|
let loginPromise = null
|
||||||
|
|
||||||
|
function hasToken() {
|
||||||
|
return !!String(uni.getStorageSync('token') || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToken() {
|
||||||
|
uni.removeStorageSync('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
function wxLoginCode() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.login({
|
||||||
|
provider: 'weixin',
|
||||||
|
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithCode(code) {
|
||||||
|
const res = await proxy.apiUrl({
|
||||||
|
url: '/api/login/mnpLogin',
|
||||||
|
method: 'POST',
|
||||||
|
data: { code }
|
||||||
|
}, false)
|
||||||
|
if (res?.code !== 1 || !res.data?.token) {
|
||||||
|
clearToken()
|
||||||
|
throw new Error(res?.msg || '登录失败')
|
||||||
|
}
|
||||||
|
uni.setStorageSync('token', res.data.token)
|
||||||
|
uni.setStorageSync('userData', res.data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOrLogin() {
|
||||||
|
if (hasToken()) {
|
||||||
|
try {
|
||||||
|
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||||||
|
if (res?.code === 1 && res.data) {
|
||||||
|
uni.setStorageSync('userData', res.data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
|
||||||
|
if (res?.code !== -1) return true
|
||||||
|
} catch (_) {
|
||||||
|
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
clearToken()
|
||||||
|
}
|
||||||
|
const code = await wxLoginCode()
|
||||||
|
return loginWithCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLoggedIn() {
|
||||||
|
if (loginPromise) return loginPromise
|
||||||
|
loginPromise = verifyOrLogin()
|
||||||
|
.then(ok => !!ok)
|
||||||
|
.catch(() => false)
|
||||||
|
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
|
||||||
|
.finally(() => { loginPromise = null })
|
||||||
|
return loginPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ensureLoggedIn }
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const EMPTY_BOARD = {
|
||||||
|
week_start: '',
|
||||||
|
week_end: '',
|
||||||
|
sex_label: '同行',
|
||||||
|
group_size: 7,
|
||||||
|
member_count: 0,
|
||||||
|
players: [],
|
||||||
|
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
|
||||||
|
invite_code: ''
|
||||||
|
}
|
||||||
|
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
|
||||||
|
|
||||||
|
function createSessionKey() {
|
||||||
|
const random = Math.random().toString(36).slice(2, 12)
|
||||||
|
return `game_${Date.now().toString(36)}_${random}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPendingPayloads() {
|
||||||
|
try {
|
||||||
|
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
|
||||||
|
if (!Array.isArray(stored)) return []
|
||||||
|
return stored.filter(item => (
|
||||||
|
item
|
||||||
|
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
|
||||||
|
&& Number(item.learned_count) >= 0
|
||||||
|
)).slice(-8)
|
||||||
|
} catch (_) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
|
||||||
|
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
|
||||||
|
*/
|
||||||
|
export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||||
|
const connected = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncStatus = ref('idle')
|
||||||
|
const lastError = ref('')
|
||||||
|
const leaderboard = ref({ ...EMPTY_BOARD })
|
||||||
|
const confirmedSessionLearned = ref(0)
|
||||||
|
|
||||||
|
let sessionKey = createSessionKey()
|
||||||
|
let syncTimer = null
|
||||||
|
let syncing = false
|
||||||
|
let queuedPayloads = readPendingPayloads()
|
||||||
|
let connectPromise = null
|
||||||
|
|
||||||
|
function persistPendingPayloads() {
|
||||||
|
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(request) {
|
||||||
|
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
|
||||||
|
return proxy.apiUrl(request, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLeaderboard(data, confirmedForSession = '') {
|
||||||
|
if (!data || !Array.isArray(data.players)) return
|
||||||
|
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
|
||||||
|
if (!inactiveSessionResponse) {
|
||||||
|
leaderboard.value = {
|
||||||
|
...EMPTY_BOARD,
|
||||||
|
...data,
|
||||||
|
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
|
||||||
|
players: data.players
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
|
||||||
|
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
|
||||||
|
}
|
||||||
|
connected.value = true
|
||||||
|
syncStatus.value = 'synced'
|
||||||
|
lastError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLeaderboard() {
|
||||||
|
const res = await api({
|
||||||
|
url: '/api/tcm/gameWeeklyLeaderboard',
|
||||||
|
method: 'GET',
|
||||||
|
data: { session_key: sessionKey }
|
||||||
|
})
|
||||||
|
if (res?.code !== 1 || !res.data) {
|
||||||
|
throw new Error(res?.msg || '同行榜加载失败')
|
||||||
|
}
|
||||||
|
applyLeaderboard(res.data, sessionKey)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptShare(inviteCode) {
|
||||||
|
const code = String(inviteCode || '').trim().toUpperCase()
|
||||||
|
if (!code) return
|
||||||
|
try {
|
||||||
|
await api({
|
||||||
|
url: '/api/tcm/gameAcceptShare',
|
||||||
|
method: 'POST',
|
||||||
|
data: { invite_code: code }
|
||||||
|
})
|
||||||
|
} catch (_) {
|
||||||
|
// 分享关系是辅助能力,不阻断进入游戏。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(inviteCode = '') {
|
||||||
|
if (connectPromise) return connectPromise
|
||||||
|
connectPromise = (async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const loggedIn = await ensureLoggedIn()
|
||||||
|
if (!loggedIn) throw new Error('登录失败')
|
||||||
|
await acceptShare(inviteCode)
|
||||||
|
await refreshLeaderboard()
|
||||||
|
if (queuedPayloads.length) flushProgress()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
connected.value = false
|
||||||
|
syncStatus.value = 'offline'
|
||||||
|
lastError.value = String(error?.message || '平台连接失败,请点击重试')
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
connectPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return connectPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginSession(existingSessionKey = '') {
|
||||||
|
if (syncTimer) clearTimeout(syncTimer)
|
||||||
|
const restoredKey = String(existingSessionKey || '').trim()
|
||||||
|
sessionKey = /^[A-Za-z0-9_-]{16,64}$/.test(restoredKey)
|
||||||
|
? restoredKey
|
||||||
|
: createSessionKey()
|
||||||
|
confirmedSessionLearned.value = 0
|
||||||
|
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
|
||||||
|
return sessionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionKey() {
|
||||||
|
return sessionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueProgress(learnedCount, score, ended = false) {
|
||||||
|
const nextPayload = {
|
||||||
|
session_key: sessionKey,
|
||||||
|
learned_count: Math.max(0, Number(learnedCount) || 0),
|
||||||
|
score: Math.max(0, Number(score) || 0),
|
||||||
|
ended: ended ? 1 : 0
|
||||||
|
}
|
||||||
|
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = queuedPayloads[existingIndex]
|
||||||
|
queuedPayloads[existingIndex] = {
|
||||||
|
...nextPayload,
|
||||||
|
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
|
||||||
|
score: Math.max(existing.score, nextPayload.score),
|
||||||
|
ended: Math.max(existing.ended, nextPayload.ended)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queuedPayloads.push(nextPayload)
|
||||||
|
}
|
||||||
|
persistPendingPayloads()
|
||||||
|
syncStatus.value = 'pending'
|
||||||
|
if (syncTimer) clearTimeout(syncTimer)
|
||||||
|
if (ended) {
|
||||||
|
flushProgress()
|
||||||
|
} else {
|
||||||
|
syncTimer = setTimeout(flushProgress, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushProgress() {
|
||||||
|
if (syncTimer) clearTimeout(syncTimer)
|
||||||
|
syncTimer = null
|
||||||
|
if (syncing || !queuedPayloads.length) return
|
||||||
|
const payload = queuedPayloads[0]
|
||||||
|
syncing = true
|
||||||
|
syncStatus.value = 'syncing'
|
||||||
|
try {
|
||||||
|
if (!connected.value) {
|
||||||
|
const loggedIn = await ensureLoggedIn()
|
||||||
|
if (!loggedIn) throw new Error('未登录')
|
||||||
|
}
|
||||||
|
const res = await api({
|
||||||
|
url: '/api/tcm/gameSubmitProgress',
|
||||||
|
method: 'POST',
|
||||||
|
data: payload
|
||||||
|
})
|
||||||
|
if (res?.code !== 1 || !res.data) {
|
||||||
|
throw new Error(res?.msg || '成绩保存失败')
|
||||||
|
}
|
||||||
|
applyLeaderboard(res.data, payload.session_key)
|
||||||
|
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
|
||||||
|
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
|
||||||
|
queuedPayloads = queuedPayloads.filter(item => (
|
||||||
|
item.session_key !== payload.session_key
|
||||||
|
|| Number(item.learned_count) > Number(payload.learned_count)
|
||||||
|
|| Number(item.score) > Number(payload.score)
|
||||||
|
|| Number(item.ended) > Number(payload.ended)
|
||||||
|
))
|
||||||
|
persistPendingPayloads()
|
||||||
|
} catch (error) {
|
||||||
|
connected.value = false
|
||||||
|
syncStatus.value = 'offline'
|
||||||
|
lastError.value = String(error?.message || '成绩暂未保存,联网后会自动重试')
|
||||||
|
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
|
||||||
|
persistPendingPayloads()
|
||||||
|
} finally {
|
||||||
|
syncing = false
|
||||||
|
if (queuedPayloads.length && connected.value) {
|
||||||
|
setTimeout(flushProgress, 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAndRefresh(learnedCount, score) {
|
||||||
|
queueProgress(learnedCount, score, false)
|
||||||
|
await flushProgress()
|
||||||
|
if (!connected.value) {
|
||||||
|
await connect()
|
||||||
|
if (queuedPayloads.length) await flushProgress()
|
||||||
|
} else {
|
||||||
|
try { await refreshLeaderboard() } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordShare() {
|
||||||
|
try {
|
||||||
|
if (!connected.value && !(await connect())) return
|
||||||
|
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
|
||||||
|
if (res?.code === 1 && res.data?.invite_code) {
|
||||||
|
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 分享本身仍可进行,统计失败不影响用户。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
if (syncTimer) clearTimeout(syncTimer)
|
||||||
|
syncTimer = null
|
||||||
|
persistPendingPayloads()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
loading,
|
||||||
|
syncStatus,
|
||||||
|
lastError,
|
||||||
|
leaderboard,
|
||||||
|
confirmedSessionLearned,
|
||||||
|
connect,
|
||||||
|
beginSession,
|
||||||
|
getSessionKey,
|
||||||
|
queueProgress,
|
||||||
|
flushProgress,
|
||||||
|
syncAndRefresh,
|
||||||
|
refreshLeaderboard,
|
||||||
|
recordShare,
|
||||||
|
dispose
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
|
||||||
|
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
|
||||||
|
*/
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'tongji_game_sfx_enabled'
|
||||||
|
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
|
||||||
|
|
||||||
|
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
|
||||||
|
const SRC = {
|
||||||
|
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
|
||||||
|
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
|
||||||
|
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
|
||||||
|
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
|
||||||
|
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
|
||||||
|
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
|
||||||
|
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
|
||||||
|
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
|
||||||
|
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音效表:src / volume / rate(playbackRate) / pool
|
||||||
|
* rate 不同可在同一素材上听出明显差异
|
||||||
|
*/
|
||||||
|
const SFX = {
|
||||||
|
// —— 基础素材别名(供分层音效直接调用)——
|
||||||
|
tap: { src: SRC.tap, volume: 0.32, rate: 1, pool: 3 },
|
||||||
|
pop: { src: SRC.pop, volume: 0.3, rate: 1, pool: 4 },
|
||||||
|
fanfare: { src: SRC.fanfare, volume: 0.56, rate: 1, pool: 2 },
|
||||||
|
blast: { src: SRC.blast, volume: 0.62, rate: 1, pool: 2 },
|
||||||
|
sparkle: { src: SRC.sparkle, volume: 0.5, rate: 1, pool: 2 },
|
||||||
|
|
||||||
|
// —— 交互 ——
|
||||||
|
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
|
||||||
|
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
|
||||||
|
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
|
||||||
|
|
||||||
|
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
|
||||||
|
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
|
||||||
|
|
||||||
|
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
|
||||||
|
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
|
||||||
|
|
||||||
|
// —— 消除(按连数)——
|
||||||
|
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||||
|
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
|
||||||
|
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
|
||||||
|
|
||||||
|
// —— 消除(按 GI 主色)——
|
||||||
|
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
|
||||||
|
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
|
||||||
|
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
|
||||||
|
|
||||||
|
// —— 连消 ——
|
||||||
|
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
|
||||||
|
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
|
||||||
|
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
|
||||||
|
|
||||||
|
// —— 道具 ——
|
||||||
|
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
|
||||||
|
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
|
||||||
|
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
|
||||||
|
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
|
||||||
|
|
||||||
|
// —— 反馈 ——
|
||||||
|
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
|
||||||
|
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
|
||||||
|
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
|
||||||
|
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
|
||||||
|
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
|
||||||
|
|
||||||
|
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
|
||||||
|
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
|
||||||
|
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
|
||||||
|
|
||||||
|
// 兼容旧名
|
||||||
|
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||||
|
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
|
||||||
|
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
|
||||||
|
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
|
||||||
|
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
|
||||||
|
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const WARMUP_SFX = [
|
||||||
|
'select', 'swap', 'swapFail', 'drop', 'dropLight',
|
||||||
|
'match3', 'match4', 'match5', 'combo2', 'combo3', 'comboMega',
|
||||||
|
'insulin', 'sparkle', 'reshuffle', 'lose'
|
||||||
|
]
|
||||||
|
|
||||||
|
function createAudioContext() {
|
||||||
|
if (typeof Audio !== 'undefined') {
|
||||||
|
const audio = new Audio()
|
||||||
|
audio.preload = 'auto'
|
||||||
|
return {
|
||||||
|
get src() { return audio.src },
|
||||||
|
set src(value) { audio.src = value },
|
||||||
|
get volume() { return audio.volume },
|
||||||
|
set volume(value) { audio.volume = value },
|
||||||
|
get playbackRate() { return audio.playbackRate },
|
||||||
|
set playbackRate(value) { audio.playbackRate = value },
|
||||||
|
play() {
|
||||||
|
const promise = audio.play()
|
||||||
|
if (promise?.catch) promise.catch(() => {})
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
audio.pause()
|
||||||
|
try { audio.currentTime = 0 } catch (_) {}
|
||||||
|
},
|
||||||
|
seek(time) {
|
||||||
|
try { audio.currentTime = time } catch (_) {}
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
audio.pause()
|
||||||
|
audio.removeAttribute('src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uni.createInnerAudioContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SfxPool {
|
||||||
|
constructor(src, size, volume, rate = 1) {
|
||||||
|
this.src = src
|
||||||
|
this.size = size
|
||||||
|
this.volume = volume
|
||||||
|
this.rate = rate
|
||||||
|
this.list = []
|
||||||
|
this.cursor = 0
|
||||||
|
this.warmedUp = false
|
||||||
|
}
|
||||||
|
|
||||||
|
createOne() {
|
||||||
|
const ctx = createAudioContext()
|
||||||
|
ctx.src = this.src
|
||||||
|
ctx.obeyMuteSwitch = false
|
||||||
|
ctx.autoplay = false
|
||||||
|
ctx.volume = this.volume
|
||||||
|
try {
|
||||||
|
ctx.playbackRate = this.rate
|
||||||
|
} catch (_) {}
|
||||||
|
this.list.push(ctx)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
while (this.list.length < this.size) this.createOne()
|
||||||
|
}
|
||||||
|
|
||||||
|
warmUp() {
|
||||||
|
if (this.warmedUp) return
|
||||||
|
const ctx = this.list[0] || this.createOne()
|
||||||
|
try {
|
||||||
|
ctx.volume = 0
|
||||||
|
ctx.play()
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
ctx.stop()
|
||||||
|
ctx.volume = this.volume
|
||||||
|
} catch (_) {}
|
||||||
|
}, 60)
|
||||||
|
} catch (_) {}
|
||||||
|
if (this.size > 1) this.cursor = 1
|
||||||
|
this.warmedUp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
play(scale = 1, rateMul = 1) {
|
||||||
|
if (this.list.length < this.size) this.create()
|
||||||
|
const ctx = this.list[this.cursor % this.list.length]
|
||||||
|
this.cursor += 1
|
||||||
|
const vol = Math.min(1, this.volume * scale)
|
||||||
|
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
|
||||||
|
try {
|
||||||
|
ctx.volume = vol
|
||||||
|
try {
|
||||||
|
ctx.playbackRate = rate
|
||||||
|
} catch (_) {}
|
||||||
|
ctx.stop()
|
||||||
|
ctx.seek(0)
|
||||||
|
ctx.play()
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
ctx.play()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.stop()
|
||||||
|
ctx.destroy()
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
this.list = []
|
||||||
|
this.warmedUp = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dominantGi(tiles = []) {
|
||||||
|
const cnt = { low: 0, mid: 0, high: 0 }
|
||||||
|
tiles.forEach((t) => {
|
||||||
|
const g = t?.type?.gi
|
||||||
|
if (g && cnt[g] !== undefined) cnt[g] += 1
|
||||||
|
})
|
||||||
|
let best = 'mid'
|
||||||
|
let max = 0
|
||||||
|
Object.entries(cnt).forEach(([k, v]) => {
|
||||||
|
if (v > max) {
|
||||||
|
max = v
|
||||||
|
best = k
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameSfx() {
|
||||||
|
const enabled = ref(true)
|
||||||
|
const pools = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = uni.getStorageSync(STORAGE_KEY)
|
||||||
|
if (stored === false || stored === '0' || stored === 0) {
|
||||||
|
enabled.value = false
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
function ensureAudioOption() {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
try {
|
||||||
|
uni.setInnerAudioOption({
|
||||||
|
obeyMuteSwitch: false,
|
||||||
|
mixWithOther: true
|
||||||
|
})
|
||||||
|
} catch (_) {}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPool(name) {
|
||||||
|
const cfg = SFX[name]
|
||||||
|
if (!cfg) return null
|
||||||
|
if (!pools[name]) {
|
||||||
|
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
|
||||||
|
}
|
||||||
|
return pools[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
function warmUp() {
|
||||||
|
if (!enabled.value) return
|
||||||
|
ensureAudioOption()
|
||||||
|
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
|
||||||
|
names.forEach((name) => {
|
||||||
|
getPool(name)?.warmUp()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(name, scale = 1, rateMul = 1) {
|
||||||
|
if (!enabled.value) return
|
||||||
|
getPool(name)?.play(scale, rateMul)
|
||||||
|
}
|
||||||
|
|
||||||
|
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
|
||||||
|
play(primary)
|
||||||
|
if (secondary) {
|
||||||
|
setTimeout(() => play(secondary, secScale), delayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
|
||||||
|
if (!enabled.value) return
|
||||||
|
const giTrack = (() => {
|
||||||
|
const gi = dominantGi(tiles)
|
||||||
|
if (gi === 'low') return 'matchLowGi'
|
||||||
|
if (gi === 'high') return 'matchHighGi'
|
||||||
|
return 'matchMidGi'
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (chain >= 3) {
|
||||||
|
playLayer('comboMega', giTrack, 90, 0.55)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chain === 2) {
|
||||||
|
playLayer('combo3', giTrack, 80, 0.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chain === 1) {
|
||||||
|
playLayer('combo2', giTrack, 70, 0.45)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchCount >= 5) {
|
||||||
|
playLayer('match5', 'matchHighGi', 85, 0.5)
|
||||||
|
} else if (matchCount >= 4) {
|
||||||
|
playLayer('match4', giTrack, 65, 0.48)
|
||||||
|
} else {
|
||||||
|
play(giTrack, 1, 1)
|
||||||
|
setTimeout(() => play('match3', 0.85), 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playDrop(count = 1) {
|
||||||
|
if (!enabled.value || count <= 0) return
|
||||||
|
const name = count >= 4 ? 'drop' : 'dropLight'
|
||||||
|
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function playMeter(delta, level) {
|
||||||
|
if (!enabled.value) return
|
||||||
|
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
|
||||||
|
else if (level < 30) play('meterWarn', 0.9, 0.95)
|
||||||
|
else if (delta > 8) play('meterWarn', 0.75)
|
||||||
|
else if (delta < -5) play('meterStable', 1.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEnabled() {
|
||||||
|
enabled.value = !enabled.value
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
|
||||||
|
} catch (_) {}
|
||||||
|
if (enabled.value) {
|
||||||
|
warmUp()
|
||||||
|
play('select')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyAll() {
|
||||||
|
Object.keys(pools).forEach((key) => {
|
||||||
|
pools[key]?.destroy()
|
||||||
|
delete pools[key]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroyAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
play,
|
||||||
|
playLayer,
|
||||||
|
playMatch,
|
||||||
|
playDrop,
|
||||||
|
playMeter,
|
||||||
|
warmUp,
|
||||||
|
toggleEnabled,
|
||||||
|
destroyAll
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 识糖小课堂 3D 食物图标候选稿 v1
|
||||||
|
|
||||||
|
- 共 42 个食物图标,文件名与 `FOODS` 中的英文键名一致。
|
||||||
|
- 单图尺寸:320 × 320,JPEG。
|
||||||
|
- 风格:中式、圆润 3D、暖色奶油背景、大轮廓、适老化高辨识度。
|
||||||
|
- 糖级、边框和角标不画入图标,由游戏界面控制,方便医生调整分类。
|
||||||
|
- `milkTea.jpg` 是唯一自带角色表情的高糖小反派候选。
|
||||||
|
- 驼乳属于产品道具,不计入 42 个普通食物图标,本轮未替换现有产品罐素材。
|
||||||
|
- 本目录当前仅作设计候选,没有接入正式游戏,也不会进入微信小程序发布包。
|
||||||
|
|
||||||
|
目录说明:
|
||||||
|
|
||||||
|
- `icons/`:可直接用于游戏的独立图标。
|
||||||
|
- `sheets/`:11 张分组预览母版,方便整体审美检查。
|
||||||
|
- `manifest.json`:食物中文名、代码键名与文件路径清单。
|
||||||
|
|
||||||
|
医学提示:图标仅表达食物外观,不代表最终糖级结论;糖级和健康说明仍需甄养堂医生审核。
|
||||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"style": "chinese-rounded-3d-elderly-friendly",
|
||||||
|
"iconSize": {
|
||||||
|
"width": 320,
|
||||||
|
"height": 320,
|
||||||
|
"format": "jpg"
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{ "key": "corn", "name": "玉米", "file": "icons/corn.jpg" },
|
||||||
|
{ "key": "egg", "name": "鸡蛋", "file": "icons/egg.jpg" },
|
||||||
|
{ "key": "tomato", "name": "番茄", "file": "icons/tomato.jpg" },
|
||||||
|
{ "key": "potato", "name": "红薯", "file": "icons/potato.jpg" },
|
||||||
|
{ "key": "cucumber", "name": "黄瓜", "file": "icons/cucumber.jpg" },
|
||||||
|
{ "key": "lettuce", "name": "生菜", "file": "icons/lettuce.jpg" },
|
||||||
|
{ "key": "grainMantou", "name": "杂粮馒头", "file": "icons/grainMantou.jpg" },
|
||||||
|
{ "key": "wonton", "name": "馄饨", "file": "icons/wonton.jpg" },
|
||||||
|
{ "key": "celery", "name": "芹菜", "file": "icons/celery.jpg" },
|
||||||
|
{ "key": "mushroom", "name": "香菇", "file": "icons/mushroom.jpg" },
|
||||||
|
{ "key": "chicken", "name": "鸡肉", "file": "icons/chicken.jpg" },
|
||||||
|
{ "key": "taro", "name": "芋头", "file": "icons/taro.jpg" },
|
||||||
|
{ "key": "broccoli", "name": "西兰花", "file": "icons/broccoli.jpg" },
|
||||||
|
{ "key": "milk", "name": "牛奶", "file": "icons/milk.jpg" },
|
||||||
|
{ "key": "brownRice", "name": "糙米饭", "file": "icons/brownRice.jpg" },
|
||||||
|
{ "key": "apple", "name": "苹果", "file": "icons/apple.jpg" },
|
||||||
|
{ "key": "strawberry", "name": "草莓", "file": "icons/strawberry.jpg" },
|
||||||
|
{ "key": "banana", "name": "香蕉", "file": "icons/banana.jpg" },
|
||||||
|
{ "key": "mango", "name": "芒果", "file": "icons/mango.jpg" },
|
||||||
|
{ "key": "tea", "name": "红茶", "file": "icons/tea.jpg" },
|
||||||
|
{ "key": "blackCoffee", "name": "黑咖啡", "file": "icons/blackCoffee.jpg" },
|
||||||
|
{ "key": "coffee", "name": "咖啡", "file": "icons/coffee.jpg" },
|
||||||
|
{ "key": "pineapple", "name": "菠萝", "file": "icons/pineapple.jpg" },
|
||||||
|
{ "key": "pepper", "name": "青椒", "file": "icons/pepper.jpg" },
|
||||||
|
{ "key": "greenBean", "name": "四季豆", "file": "icons/greenBean.jpg" },
|
||||||
|
{ "key": "shrimp", "name": "虾仁", "file": "icons/shrimp.jpg" },
|
||||||
|
{ "key": "udon", "name": "乌冬面", "file": "icons/udon.jpg" },
|
||||||
|
{ "key": "onion", "name": "洋葱", "file": "icons/onion.jpg" },
|
||||||
|
{ "key": "congee", "name": "白米粥", "file": "icons/congee.jpg" },
|
||||||
|
{ "key": "eightCongee", "name": "八宝粥", "file": "icons/eightCongee.jpg" },
|
||||||
|
{ "key": "centuryCongee", "name": "皮蛋瘦肉粥", "file": "icons/centuryCongee.jpg" },
|
||||||
|
{ "key": "milletCongee", "name": "小米粥", "file": "icons/milletCongee.jpg" },
|
||||||
|
{ "key": "orangeJuice", "name": "柳橙汁", "file": "icons/orangeJuice.jpg" },
|
||||||
|
{ "key": "soda", "name": "含糖汽水", "file": "icons/soda.jpg" },
|
||||||
|
{ "key": "riceNoodleSoup", "name": "汤粉", "file": "icons/riceNoodleSoup.jpg" },
|
||||||
|
{ "key": "noodleSoup", "name": "汤面", "file": "icons/noodleSoup.jpg" },
|
||||||
|
{ "key": "milkTea", "name": "含糖奶茶", "file": "icons/milkTea.jpg" },
|
||||||
|
{ "key": "tofu", "name": "豆腐", "file": "icons/tofu.jpg" },
|
||||||
|
{ "key": "spinach", "name": "菠菜", "file": "icons/spinach.jpg" },
|
||||||
|
{ "key": "carrot", "name": "胡萝卜", "file": "icons/carrot.jpg" },
|
||||||
|
{ "key": "fish", "name": "鱼", "file": "icons/fish.jpg" },
|
||||||
|
{ "key": "redBeanCongee", "name": "软烂红豆粥", "file": "icons/redBeanCongee.jpg" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,653 @@
|
|||||||
|
.eg-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #173b32;
|
||||||
|
background: linear-gradient(180deg, #eefbf4 0%, #f9f3dd 56%, #fffaf1 100%);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eg-confetti-layer { position: fixed; z-index: 80; inset: 0; overflow: hidden; pointer-events: none; }
|
||||||
|
.eg-confetti-piece {
|
||||||
|
position: absolute;
|
||||||
|
top: -70rpx;
|
||||||
|
display: block;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
box-shadow: 0 2rpx 4rpx rgba(0,0,0,.12);
|
||||||
|
animation-name: egConfettiFall;
|
||||||
|
animation-timing-function: cubic-bezier(.18,.72,.35,1);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
.eg-confetti-piece.is-emoji { width: auto !important; height: auto !important; background: transparent !important; box-shadow: none; font-size: 42rpx; }
|
||||||
|
|
||||||
|
.eg-nav {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
box-sizing: border-box;
|
||||||
|
grid-template-columns: 156rpx minmax(0, 1fr) 156rpx;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 12rpx;
|
||||||
|
padding-right: 28rpx;
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
padding-left: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eg-nav-leading { display: flex; width: 156rpx; justify-content: flex-start; }
|
||||||
|
|
||||||
|
.eg-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2rpx solid rgba(21, 94, 75, .12);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: rgba(255,255,255,.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
|
||||||
|
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
|
||||||
|
|
||||||
|
.eg-title-wrap { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: center; }
|
||||||
|
.eg-title-wrap > .eg-title,
|
||||||
|
.eg-title-wrap > .eg-subtitle { transition: opacity .08s ease; }
|
||||||
|
.eg-title-wrap.is-noticing > .eg-title,
|
||||||
|
.eg-title-wrap.is-noticing > .eg-subtitle { opacity: .16; }
|
||||||
|
.eg-nav-notice {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5rpx 8rpx;
|
||||||
|
border: 1rpx solid rgba(21,94,75,.09);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
color: rgba(38,86,72,.82);
|
||||||
|
background: rgba(255,255,255,.58);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(13,64,51,.06);
|
||||||
|
text-align: center;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: egNavNotice 1.05s ease-out both;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.eg-nav-notice-title,
|
||||||
|
.eg-nav-notice-detail {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.eg-nav-notice-title { font-size: 23rpx; font-weight: 800; }
|
||||||
|
.eg-nav-notice-detail { margin-top: 2rpx; color: rgba(154,106,42,.78); font-size: 20rpx; font-weight: 700; }
|
||||||
|
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
|
||||||
|
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
|
||||||
|
.eg-content { padding: 0 16rpx 48rpx; }
|
||||||
|
|
||||||
|
.eg-score-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
padding: 18rpx 24rpx;
|
||||||
|
border: 2rpx solid rgba(21, 94, 75, .1);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: rgba(255,255,255,.9);
|
||||||
|
box-shadow: 0 10rpx 28rpx rgba(32, 78, 43, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eg-stat { display: flex; flex: 1; flex-direction: column; align-items: center; }
|
||||||
|
.eg-stat--main { border-right: 2rpx solid #e2eee8; border-left: 2rpx solid #e2eee8; }
|
||||||
|
.eg-stat-label { color: #71847d; font-size: 24rpx; }
|
||||||
|
.eg-stat-value { margin-top: 4rpx; color: #204e2b; font-size: 38rpx; font-weight: 800; }
|
||||||
|
.eg-stat-score { margin-top: 2rpx; color: #e16f24; font-size: 46rpx; font-weight: 900; }
|
||||||
|
.eg-stat--rank { position: relative; cursor: pointer; }
|
||||||
|
.eg-stat--rank.is-locked { opacity: .52; }
|
||||||
|
.eg-stat-rank-value { margin-top: 1rpx; color: #176b52; font-size: 35rpx; font-weight: 1000; line-height: 1.08; }
|
||||||
|
.eg-stat-rank-hint { margin-top: 2rpx; color: #df7427; font-size: 18rpx; font-weight: 800; }
|
||||||
|
|
||||||
|
.eg-task-card {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: rgba(255, 249, 223, .94);
|
||||||
|
box-shadow: 0 8rpx 22rpx rgba(118, 89, 25, .08);
|
||||||
|
}
|
||||||
|
.eg-task-card { position: relative; overflow: visible; }
|
||||||
|
|
||||||
|
.eg-task-reward-flight {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 72;
|
||||||
|
top: 4rpx;
|
||||||
|
left: 42%;
|
||||||
|
display: flex;
|
||||||
|
width: 112rpx;
|
||||||
|
height: 92rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 4rpx solid rgba(255,255,255,.96);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #fff7e8;
|
||||||
|
box-shadow: 0 10rpx 24rpx rgba(159,94,23,.28);
|
||||||
|
animation: egTaskRewardFlight 1.12s cubic-bezier(.18,.78,.22,1) both;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.eg-task-reward-flight > image { position: relative; z-index: 2; width: 78rpx; height: 66rpx; }
|
||||||
|
.eg-task-reward-flight > text { position: absolute; z-index: 3; top: -14rpx; right: -16rpx; display: flex; width: 48rpx; height: 48rpx; align-items: center; justify-content: center; border: 4rpx solid #fff; border-radius: 50%; color: #fff; background: #ed7625; box-shadow: 0 5rpx 12rpx rgba(191,75,19,.3); font-size: 25rpx; font-weight: 1000; }
|
||||||
|
.eg-task-reward-glow { position: absolute; z-index: 1; inset: -15rpx; border-radius: 32rpx; background: radial-gradient(circle, rgba(255,210,67,.52), rgba(255,210,67,0) 68%); animation: egRewardGlow .42s ease-in-out infinite alternate; }
|
||||||
|
|
||||||
|
.eg-task-head { display: flex; align-items: center; justify-content: space-between; color: #7c5b1b; font-size: 27rpx; font-weight: 700; }
|
||||||
|
.eg-task-name { display: flex; align-items: center; gap: 10rpx; }
|
||||||
|
.eg-task-count { color: #9a6718; font-size: 29rpx; }
|
||||||
|
.eg-progress { height: 14rpx; margin-top: 12rpx; overflow: hidden; border-radius: 999rpx; background: #eadfbd; }
|
||||||
|
.eg-progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #f0a128, #f4c34f); transition: width .25s; }
|
||||||
|
|
||||||
|
.eg-board-shell {
|
||||||
|
position: relative;
|
||||||
|
padding: 14rpx;
|
||||||
|
border: 2rpx solid rgba(34, 103, 79, .16);
|
||||||
|
border-radius: 34rpx;
|
||||||
|
background: rgba(255,255,255,.92);
|
||||||
|
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
|
||||||
|
}
|
||||||
|
.eg-board-shell.is-flowing {
|
||||||
|
border-color: rgba(245, 158, 11, .72);
|
||||||
|
box-shadow: 0 0 0 5rpx rgba(255, 221, 89, .2), 0 18rpx 46rpx rgba(234, 88, 12, .2);
|
||||||
|
animation: egFlowShell 1.05s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
|
||||||
|
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
|
||||||
|
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
|
||||||
|
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||||
|
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
|
||||||
|
.eg-flow-state { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 14rpx; border: 3rpx solid #fff3a3; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #f97316, #dc2626); box-shadow: 0 5rpx 16rpx rgba(220,38,38,.3); font-size: 25rpx; font-weight: 1000; white-space: nowrap; animation: egFlowBadge .62s ease-in-out infinite alternate; }
|
||||||
|
.eg-flow-state > text:last-child { min-width: 62rpx; color: #fff7a8; text-align: right; }
|
||||||
|
.eg-flow-state.is-paused { border-color: #d9f6ff; background: linear-gradient(135deg, #1687b4, #3155a6); box-shadow: 0 5rpx 16rpx rgba(34,109,166,.28); animation: none; }
|
||||||
|
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
|
||||||
|
.eg-risk-dots { display: flex; gap: 5rpx; }
|
||||||
|
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
|
||||||
|
.eg-risk-dot.on { background: #e96b3b; box-shadow: 0 0 0 3rpx rgba(233,107,59,.12); }
|
||||||
|
.eg-board { display: flex; flex-direction: column; gap: 8rpx; opacity: 1; transition: opacity .15s; }
|
||||||
|
.eg-board.is-busy { opacity: .82; }
|
||||||
|
.eg-row { display: flex; gap: 8rpx; }
|
||||||
|
|
||||||
|
.eg-cell {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
height: 166rpx;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 5rpx solid transparent;
|
||||||
|
border-radius: 26rpx;
|
||||||
|
box-shadow: inset 0 -5rpx 0 rgba(0,0,0,.05), 0 5rpx 12rpx rgba(31, 66, 53, .08);
|
||||||
|
transition: transform .16s cubic-bezier(.2,.8,.2,1), border-color .15s, opacity .16s, filter .16s;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eg-cell.is-dragging { transition: transform .055s linear, border-color .15s; }
|
||||||
|
.eg-cell.is-selected { z-index: 2; border-color: #167d61; transform: scale(1.06); box-shadow: 0 0 0 6rpx rgba(22,125,97,.14); }
|
||||||
|
.eg-cell.is-camel-target { border-color: #f59e0b; animation: egPulse 1s infinite; }
|
||||||
|
.eg-replace-target { position: absolute; z-index: 7; right: 7rpx; bottom: 6rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; background: #1570a6; box-shadow: 0 3rpx 9rpx rgba(20,91,135,.28); font-size: 20rpx; font-weight: 900; }
|
||||||
|
.eg-cell.is-clearing {
|
||||||
|
z-index: 8;
|
||||||
|
border-color: #fff;
|
||||||
|
animation: egHit .22s cubic-bezier(.2,.9,.3,1) forwards;
|
||||||
|
filter: brightness(1.28) saturate(1.25);
|
||||||
|
}
|
||||||
|
.eg-cell.is-yellow,
|
||||||
|
.eg-cell.is-cream,
|
||||||
|
.eg-cell.is-red,
|
||||||
|
.eg-cell.is-purple { background: #fffaf0; }
|
||||||
|
.eg-cell.is-level-low { background: #f4faf6; }
|
||||||
|
.eg-cell.is-level-mid { background: #fff9eb; }
|
||||||
|
.eg-cell.is-orange,
|
||||||
|
.eg-cell.is-level-high { background: #fff0e4; }
|
||||||
|
.eg-cell.is-milk { background: #edf7ff; }
|
||||||
|
.eg-cell.is-high { border-color: #ef7b45; }
|
||||||
|
.eg-cell.is-villain {
|
||||||
|
background: radial-gradient(circle at 50% 38%, #fff7e8 0%, #ffe9d8 70%, #ffddc6 100%);
|
||||||
|
box-shadow: inset 0 -5rpx 0 rgba(154,57,25,.08), 0 6rpx 15rpx rgba(191,72,31,.16);
|
||||||
|
}
|
||||||
|
.eg-cell.is-villain .eg-food-image { animation: egVillainPeek 2.2s ease-in-out infinite; }
|
||||||
|
.eg-cell.is-villain.is-clearing .eg-food-image,
|
||||||
|
.eg-cell.is-villain.is-dragging .eg-food-image { animation: none; }
|
||||||
|
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
|
||||||
|
.eg-food-image.is-large-square { width: 136rpx; height: 102rpx; margin-top: 2rpx; filter: saturate(1.1) contrast(1.06); }
|
||||||
|
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
|
||||||
|
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
|
||||||
|
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
|
||||||
|
.eg-sugar-tag.is-mid { color: #735a15; border-color: rgba(255,255,255,.9); background: #f7e7a8; box-shadow: 0 2rpx 6rpx rgba(137,91,0,.15); }
|
||||||
|
.eg-sugar-tag.is-high { background: #d9431f; box-shadow: 0 4rpx 10rpx rgba(163,45,21,.32); }
|
||||||
|
.eg-sugar-tag.is-prop { background: #2563a8; }
|
||||||
|
.eg-special-mark { position: absolute; z-index: 12; bottom: 5rpx; left: 6rpx; display: flex; width: 40rpx; height: 40rpx; align-items: center; justify-content: center; border: 3rpx solid rgba(255,255,255,.96); border-radius: 50%; color: #fff; background: #6d28d9; box-shadow: 0 4rpx 11rpx rgba(73,31,130,.3); font-size: 23rpx; font-weight: 900; }
|
||||||
|
.eg-special-mark.is-row { width: 58rpx; height: 40rpx; border-radius: 999rpx; background: linear-gradient(135deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
|
||||||
|
.eg-special-mark.is-col { width: 40rpx; height: 58rpx; border-radius: 999rpx; background: linear-gradient(180deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
|
||||||
|
.eg-special-mark.is-burst { width: 48rpx; height: 48rpx; border-color: #fff3a8; border-radius: 15rpx 50% 50%; background: linear-gradient(145deg, #7c3aed 5%, #dd3b70 48%, #ff8a18 100%); box-shadow: 0 4rpx 13rpx rgba(108,35,155,.38), 0 0 12rpx rgba(255,179,38,.3); animation: egLReady 1.25s ease-in-out infinite alternate; }
|
||||||
|
.eg-mini-flame { position: relative; width: 25rpx; height: 30rpx; border-radius: 70% 32% 68% 40%; background: linear-gradient(135deg, #fff8a8 8%, #ffc21f 42%, #f04420 84%); box-shadow: 0 0 14rpx rgba(255,230,92,.95); transform: rotate(43deg); }
|
||||||
|
.eg-mini-flame-core { position: absolute; right: 4rpx; bottom: 3rpx; width: 10rpx; height: 15rpx; border-radius: 70% 35% 70% 42%; background: #fffbd1; box-shadow: 0 0 7rpx rgba(255,255,210,.95); }
|
||||||
|
.eg-l-special { position: relative; width: 29rpx; height: 29rpx; filter: drop-shadow(0 1rpx 2rpx rgba(73,22,105,.3)); }
|
||||||
|
.eg-l-arm { position: absolute; left: 3rpx; bottom: 3rpx; border-radius: 999rpx; background: #fff9b8; box-shadow: 0 0 6rpx rgba(255,249,184,.92); }
|
||||||
|
.eg-l-arm.is-vertical { width: 8rpx; height: 25rpx; }
|
||||||
|
.eg-l-arm.is-horizontal { width: 25rpx; height: 8rpx; }
|
||||||
|
.eg-l-core { position: absolute; left: 1rpx; bottom: 1rpx; width: 12rpx; height: 12rpx; border: 2rpx solid #fff; border-radius: 50%; background: #ff5b25; }
|
||||||
|
.eg-cell.is-fire-clearing { border-color: #ff7a1a; box-shadow: inset 0 0 25rpx rgba(255,102,18,.36), 0 0 18rpx rgba(255,105,20,.48); filter: brightness(1.35) saturate(1.42); }
|
||||||
|
.eg-cell.is-clearing.is-fire-clearing { animation: egFireCellHit .36s cubic-bezier(.2,.78,.22,1) forwards; }
|
||||||
|
.eg-fire-clear { position: absolute; z-index: 14; inset: 2rpx; overflow: hidden; border: 3rpx solid rgba(255,210,71,.92); border-radius: 22rpx; opacity: .96; background: linear-gradient(180deg, rgba(255,230,86,.12), rgba(239,63,27,.22)); pointer-events: none; }
|
||||||
|
.eg-fire-glow { position: absolute; inset: 6%; border-radius: 18rpx; background: radial-gradient(circle, rgba(255,246,177,.74), rgba(255,131,22,.32) 47%, rgba(222,47,23,0) 75%); animation: egFireGlow .34s ease-out both; }
|
||||||
|
.eg-fire-sweep { position: absolute; top: 23%; left: -48%; width: 168%; height: 55%; border-radius: 60% 45% 55% 42%; background: linear-gradient(90deg, rgba(255,184,38,0), rgba(255,238,124,.94) 28%, rgba(255,133,22,.92) 53%, rgba(226,54,25,.82) 72%, rgba(255,184,38,0)); box-shadow: 0 0 27rpx rgba(255,91,17,.72); animation: egFireSweep .34s cubic-bezier(.2,.78,.22,1) both; }
|
||||||
|
.eg-fire-flame { position: absolute; bottom: 13%; width: 25rpx; height: 39rpx; border-radius: 72% 35% 68% 42%; background: linear-gradient(145deg, #fff7a6 4%, #ffc329 39%, #ff7419 66%, #dc321f 100%); box-shadow: 0 0 15rpx rgba(255,124,21,.88); transform: rotate(42deg); animation: egFireFlame .34s ease-out both; }
|
||||||
|
.eg-fire-flame > view { position: absolute; right: 5rpx; bottom: 4rpx; width: 10rpx; height: 18rpx; border-radius: 70% 35% 70% 42%; background: #fffbd6; }
|
||||||
|
.eg-fire-flame.is-left { left: 16%; animation-delay: .01s; }
|
||||||
|
.eg-fire-flame.is-center { left: 43%; bottom: 19%; transform: rotate(42deg) scale(1.18); animation-delay: .035s; }
|
||||||
|
.eg-fire-flame.is-right { right: 15%; animation-delay: .065s; }
|
||||||
|
.eg-fire-spark { position: absolute; width: 10rpx; height: 17rpx; border-radius: 70% 35% 70% 42%; background: #ffe45e; box-shadow: 0 0 11rpx rgba(255,139,28,.9); animation: egFireSpark .34s ease-out both; }
|
||||||
|
.eg-fire-spark.is-one { top: 46%; left: 25%; }
|
||||||
|
.eg-fire-spark.is-two { top: 34%; left: 62%; animation-delay: .035s; }
|
||||||
|
.eg-fire-spark.is-three { top: 52%; left: 78%; animation-delay: .07s; }
|
||||||
|
.eg-board-tip { display: flex; align-items: center; justify-content: center; gap: 8rpx; min-height: 58rpx; margin-top: 12rpx; color: #684817; font-size: 26rpx; font-weight: 700; text-align: center; }
|
||||||
|
.eg-impact-text {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 46%;
|
||||||
|
left: 50%;
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
border: 4rpx solid rgba(255,255,255,.9);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #e4572e);
|
||||||
|
box-shadow: 0 10rpx 28rpx rgba(190, 70, 25, .35);
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: egImpact .52s cubic-bezier(.16,.82,.3,1) forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.eg-impact-text.is-level-2 { font-size: 40rpx; background: linear-gradient(135deg, #8b5cf6, #ec4899); }
|
||||||
|
.eg-impact-text.is-level-3 { font-size: 46rpx; background: linear-gradient(135deg, #ef4444, #f59e0b); box-shadow: 0 12rpx 36rpx rgba(239,68,68,.42); }
|
||||||
|
.eg-jackpot {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
top: 48%;
|
||||||
|
left: 50%;
|
||||||
|
width: 540rpx;
|
||||||
|
padding: 24rpx 22rpx 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 7rpx solid #ffd83d;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: radial-gradient(circle at 50% 0%, #ef4444, #9f1239 65%, #64102a);
|
||||||
|
box-shadow: 0 0 0 7rpx #fff2a8, 0 22rpx 52rpx rgba(100,16,42,.5);
|
||||||
|
text-align: center;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
animation: egJackpotIn 1.35s cubic-bezier(.16,.85,.25,1) forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.eg-jackpot.is-match-4 { background: radial-gradient(circle at 50% 0%, #8b5cf6, #5b21b6 68%, #35106d); }
|
||||||
|
.eg-jackpot.is-match-5 { border-color: #fff36a; background: radial-gradient(circle at 50% 0%, #ff6b24, #d7193f 58%, #760c2a); animation-duration: 1.9s; }
|
||||||
|
.eg-jackpot.is-match-3 {
|
||||||
|
width: 414rpx;
|
||||||
|
padding: 20rpx 22rpx 18rpx;
|
||||||
|
border-width: 4rpx;
|
||||||
|
border-color: #f1c663;
|
||||||
|
color: #31574b;
|
||||||
|
background: linear-gradient(155deg, #f7fff9, #fff5d9);
|
||||||
|
box-shadow: 0 0 0 4rpx rgba(255,255,255,.84), 0 14rpx 34rpx rgba(98,82,39,.2);
|
||||||
|
animation: egTripleIn 1.12s cubic-bezier(.2,.82,.28,1) forwards;
|
||||||
|
}
|
||||||
|
.eg-jackpot.is-match-3 .eg-jackpot-lights { display: none; }
|
||||||
|
.eg-jackpot.is-match-3 .eg-jackpot-kicker { color: #5b8c73; font-size: 21rpx; letter-spacing: 3rpx; text-shadow: none; }
|
||||||
|
.eg-jackpot.is-match-3 .eg-jackpot-title { color: #365b4e; font-size: 31rpx; text-shadow: none; }
|
||||||
|
.eg-jackpot.is-match-3 .eg-jackpot-reward { color: #ad7021; font-size: 25rpx; }
|
||||||
|
.eg-jackpot.is-match-3 .eg-jackpot-note { color: #829087; }
|
||||||
|
.eg-triple-cans { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 12rpx 0 10rpx; }
|
||||||
|
.eg-triple-can { display: flex; width: 78rpx; height: 76rpx; align-items: center; justify-content: center; border: 3rpx solid #f4d996; border-radius: 19rpx; background: rgba(255,255,255,.9); box-shadow: 0 5rpx 12rpx rgba(128,92,30,.12); animation: egTripleCan .42s cubic-bezier(.2,.86,.26,1) both; }
|
||||||
|
.eg-triple-can:nth-child(2) { animation-delay: .07s; }
|
||||||
|
.eg-triple-can:nth-child(3) { animation-delay: .14s; }
|
||||||
|
.eg-triple-can > image { width: 62rpx; height: 58rpx; }
|
||||||
|
|
||||||
|
.eg-jackpot.is-match-4 {
|
||||||
|
width: 548rpx;
|
||||||
|
padding: 25rpx 22rpx 22rpx;
|
||||||
|
border-color: #ffd95b;
|
||||||
|
background: radial-gradient(circle at 50% -10%, #a97cff, #6031b5 58%, #341066);
|
||||||
|
box-shadow: 0 0 0 6rpx #fff0a9, 0 0 36rpx rgba(255,210,76,.5), 0 24rpx 58rpx rgba(53,16,109,.48);
|
||||||
|
animation: egFourIn 1.88s cubic-bezier(.16,.84,.24,1) forwards;
|
||||||
|
}
|
||||||
|
.eg-jackpot.is-match-4 .eg-jackpot-kicker { color: #ffef8d; font-size: 24rpx; letter-spacing: 3rpx; }
|
||||||
|
.eg-jackpot.is-match-4 .eg-jackpot-title { margin-top: 9rpx; color: #fff; font-size: 39rpx; }
|
||||||
|
.eg-jackpot.is-match-4 .eg-jackpot-reward { color: #fff2a8; }
|
||||||
|
.eg-four-teaser { position: relative; margin-top: 13rpx; }
|
||||||
|
.eg-four-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
|
||||||
|
.eg-four-can { display: flex; width: 76rpx; height: 92rpx; align-items: center; justify-content: center; border: 4rpx solid #ffe078; border-radius: 18rpx; background: linear-gradient(180deg, #fffef7, #ffe9a5); box-shadow: inset 0 -5rpx 0 rgba(171,106,17,.12), 0 7rpx 13rpx rgba(34,12,67,.28); animation: egFourCan .48s cubic-bezier(.18,.9,.25,1.2) both; }
|
||||||
|
.eg-four-can:nth-child(2) { animation-delay: .07s; }
|
||||||
|
.eg-four-can:nth-child(3) { animation-delay: .14s; }
|
||||||
|
.eg-four-can:nth-child(4) { animation-delay: .21s; }
|
||||||
|
.eg-four-can > image { width: 64rpx; height: 70rpx; }
|
||||||
|
.eg-four-can.is-locked { border-style: dashed; border-color: rgba(255,241,161,.86); color: #fff6ae; background: rgba(48,18,91,.54); box-shadow: inset 0 0 16rpx rgba(255,225,91,.18), 0 0 18rpx rgba(255,222,91,.36); font-size: 48rpx; font-weight: 1000; animation: egLockedCan .68s .34s ease-in-out infinite alternate; }
|
||||||
|
.eg-four-promise { display: inline-flex; margin-top: 15rpx; padding: 8rpx 18rpx; border: 2rpx solid rgba(255,247,184,.72); border-radius: 999rpx; color: #3d176f; background: linear-gradient(90deg, #fff3a3, #ffd85b); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); font-size: 25rpx; font-weight: 1000; animation: egPromisePulse .72s ease-in-out infinite alternate; }
|
||||||
|
.eg-five-celebration { position: relative; margin: 15rpx 0 10rpx; }
|
||||||
|
.eg-five-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
|
||||||
|
.eg-five-can { display: flex; width: 78rpx; height: 93rpx; align-items: center; justify-content: center; border: 4rpx solid #fff17b; border-radius: 18rpx; background: linear-gradient(180deg, #fff, #fff2ac); box-shadow: inset 0 -5rpx 0 rgba(184,91,12,.13), 0 7rpx 0 #bd5b13, 0 0 16rpx rgba(255,240,101,.5); animation: egFiveCan .56s cubic-bezier(.16,.92,.24,1.2) both; }
|
||||||
|
.eg-five-can:nth-child(2) { animation-delay: .07s; }
|
||||||
|
.eg-five-can:nth-child(3) { animation-delay: .14s; }
|
||||||
|
.eg-five-can:nth-child(4) { animation-delay: .21s; }
|
||||||
|
.eg-five-can:nth-child(5) { animation-delay: .28s; }
|
||||||
|
.eg-five-can > image { width: 65rpx; height: 72rpx; }
|
||||||
|
.eg-five-sevens { display: flex; justify-content: center; gap: 8rpx; margin-top: 13rpx; }
|
||||||
|
.eg-five-sevens > text { display: flex; width: 58rpx; height: 55rpx; align-items: center; justify-content: center; border: 3rpx solid #fff5a7; border-radius: 13rpx; color: #fff36a; background: linear-gradient(155deg, #d7193f, #9f1239); box-shadow: 0 5rpx 0 #70102b, 0 0 13rpx rgba(255,243,106,.45); font-size: 43rpx; font-weight: 1000; line-height: 1; text-shadow: 0 3rpx 0 #8f1733; }
|
||||||
|
.eg-jackpot-lights { position: absolute; inset: 8rpx; border: 4rpx dotted rgba(255,255,255,.88); border-radius: 25rpx; animation: egLights .24s steps(2) infinite; }
|
||||||
|
.eg-jackpot-kicker { position: relative; display: block; color: #fff36a; font-size: 28rpx; font-weight: 900; letter-spacing: 6rpx; text-shadow: 0 3rpx 0 rgba(83,19,25,.45); }
|
||||||
|
.eg-jackpot-reels { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 13rpx 0; }
|
||||||
|
.eg-jackpot-reel { display: flex; width: 112rpx; height: 126rpx; flex-direction: column; align-items: center; justify-content: center; border: 6rpx solid #ffcf31; border-radius: 20rpx; background: linear-gradient(180deg, #fff 0%, #fff7cf 48%, #ffd55e 50%, #fff 53%, #fff8dc 100%); box-shadow: inset 0 0 18rpx rgba(137,78,10,.25), 0 7rpx 0 #a85a0a; animation: egReelStop .52s cubic-bezier(.2,.9,.25,1) both; }
|
||||||
|
.eg-jackpot-reel:nth-child(2) { animation-delay: .09s; }
|
||||||
|
.eg-jackpot-reel:nth-child(3) { animation-delay: .18s; }
|
||||||
|
.eg-jackpot-seven { color: #e11d48; font-size: 61rpx; font-weight: 1000; line-height: .85; text-shadow: 0 3rpx 0 #ffd1d8; }
|
||||||
|
.eg-jackpot-milk { width: 54rpx; height: 36rpx; margin-top: 3rpx; border-radius: 7rpx; }
|
||||||
|
.eg-jackpot-title { position: relative; display: block; font-size: 37rpx; font-weight: 900; text-shadow: 0 4rpx 0 rgba(73,10,30,.5); }
|
||||||
|
.eg-jackpot-points { position: relative; display: block; margin-top: 2rpx; color: #fff36a; font-size: 66rpx; font-weight: 1000; line-height: 1.05; letter-spacing: 2rpx; text-shadow: 0 5rpx 0 #9e2813, 0 0 18rpx rgba(255,243,106,.85); animation: egPointsPop .62s .42s cubic-bezier(.16,.9,.25,1.25) both; }
|
||||||
|
.eg-jackpot-reward { position: relative; display: block; margin-top: 7rpx; color: #fff5a5; font-size: 27rpx; font-weight: 800; line-height: 1.35; }
|
||||||
|
.eg-jackpot-note { position: relative; display: block; margin-top: 8rpx; color: rgba(255,255,255,.8); font-size: 22rpx; }
|
||||||
|
.eg-tools { margin-top: 16rpx; }
|
||||||
|
.eg-tool { display: flex; align-items: center; padding: 16rpx 18rpx; border: 3rpx solid #c9e0d6; border-radius: 26rpx; background: rgba(255,255,255,.92); box-shadow: 0 7rpx 18rpx rgba(24, 84, 63, .07); }
|
||||||
|
.eg-tool.active { border-color: #f59e0b; background: #fffbeb; }
|
||||||
|
.eg-tool.disabled { opacity: .45; }
|
||||||
|
.eg-tool-icon { position: relative; display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; overflow: visible; border-radius: 24rpx; font-size: 46rpx; background: linear-gradient(145deg, #e8f7ff, #d4ecff); box-shadow: inset 0 -4rpx 0 rgba(33,104,151,.08); }
|
||||||
|
.eg-tool-food-image { width: 76rpx; height: 58rpx; border-radius: 14rpx; }
|
||||||
|
.eg-replace-badge { position: absolute; right: -12rpx; bottom: -8rpx; display: flex; min-width: 78rpx; height: 38rpx; align-items: center; justify-content: center; gap: 2rpx; padding: 0 8rpx; border: 3rpx solid #fff; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #2389b9, #145d91); box-shadow: 0 5rpx 12rpx rgba(20,93,145,.3); }
|
||||||
|
.eg-replace-badge .tj-icon-wrap { width: 22rpx; height: 22rpx; }
|
||||||
|
.eg-replace-badge > text { font-size: 20rpx; font-weight: 900; line-height: 1; }
|
||||||
|
.eg-tool-copy { display: flex; flex: 1; flex-direction: column; margin-left: 16rpx; }
|
||||||
|
.eg-tool-name { color: #244a3d; font-size: 29rpx; font-weight: 800; }
|
||||||
|
.eg-tool-desc { margin-top: 5rpx; color: #6c8179; font-size: 25rpx; }
|
||||||
|
.eg-tool-count { color: #155e4b; font-size: 32rpx; font-weight: 900; }
|
||||||
|
.eg-legend { display: flex; justify-content: center; gap: 18rpx; margin-top: 18rpx; color: #53685f; font-size: 25rpx; font-weight: 700; }
|
||||||
|
.eg-dot--safe { color: #4cae7b; }
|
||||||
|
.eg-dot--mid { color: #e2aa19; }
|
||||||
|
.eg-dot--high { color: #e4572e; }
|
||||||
|
|
||||||
|
.eg-weekly-overlay { position: fixed; z-index: 90; inset: 0; display: flex; align-items: center; justify-content: center; padding: 28rpx; background: rgba(16, 55, 43, .66); backdrop-filter: blur(5px); }
|
||||||
|
.eg-weekly-card { width: 100%; max-width: 664rpx; max-height: calc(100vh - 56rpx); overflow-y: auto; padding: 28rpx 26rpx 22rpx; border: 5rpx solid rgba(255,255,255,.96); border-radius: 38rpx; background: linear-gradient(160deg, #f5fff9 0%, #fffdf3 56%, #fff4db 100%); box-shadow: 0 30rpx 80rpx rgba(5,45,32,.38); animation: egWeeklyIn .36s cubic-bezier(.18,.88,.27,1.08) both; }
|
||||||
|
.eg-weekly-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
|
||||||
|
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
|
||||||
|
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
|
||||||
|
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
|
||||||
|
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
|
||||||
|
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
|
||||||
|
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
|
||||||
|
.eg-weekly-progress-label { display: block; color: #657b73; font-size: 22rpx; font-weight: 700; }
|
||||||
|
.eg-weekly-progress-value { display: block; margin-top: -2rpx; color: #e06b24; font-size: 43rpx; font-weight: 1000; }
|
||||||
|
.eg-weekly-progress-copy { display: flex; flex-direction: column; align-items: flex-end; color: #235b49; font-size: 24rpx; font-weight: 900; line-height: 1.55; }
|
||||||
|
.eg-weekly-progress-copy text:last-child { color: #b86820; }
|
||||||
|
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
|
||||||
|
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
|
||||||
|
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
|
||||||
|
.eg-weekly-row.is-waiting { color: #8a9b94; border-style: dashed; border-color: #dbe8e2; background: rgba(247,251,249,.72); }
|
||||||
|
.eg-weekly-row.is-waiting .eg-weekly-avatar { color: #6e887e; border-color: #dce9e3; background: #edf4f1; box-shadow: none; }
|
||||||
|
.eg-weekly-row.is-waiting .eg-weekly-count { color: #9baba4; }
|
||||||
|
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
|
||||||
|
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
|
||||||
|
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
|
||||||
|
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
|
||||||
|
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
|
||||||
|
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
|
||||||
|
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
|
||||||
|
.eg-weekly-avatar.is-tone-5 { background: #a673bd; }
|
||||||
|
.eg-weekly-avatar.is-tone-6 { background: #448e9c; }
|
||||||
|
.eg-weekly-avatar.is-tone-7 { background: #c27b49; }
|
||||||
|
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
|
||||||
|
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
|
||||||
|
.eg-weekly-connection { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; margin-top: 13rpx; padding: 12rpx 15rpx; border: 2rpx solid #e4d7b2; border-radius: 18rpx; color: #6b6655; background: #fff9e8; font-size: 21rpx; font-weight: 700; }
|
||||||
|
.eg-weekly-connection > text:first-child { flex: 1; }
|
||||||
|
.eg-weekly-retry { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 999rpx; color: #fff; background: #23765c; font-weight: 900; }
|
||||||
|
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
|
||||||
|
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
|
||||||
|
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
|
||||||
|
.eg-cheer-top { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.eg-cheer-source { color: #8e5c18; font-size: 21rpx; font-weight: 900; }
|
||||||
|
.eg-cheer-card.is-family .eg-cheer-source { color: #77539c; }
|
||||||
|
.eg-cheer-change { padding: 5rpx 12rpx; border-radius: 999rpx; color: #176b52; background: rgba(255,255,255,.75); font-size: 19rpx; font-weight: 900; }
|
||||||
|
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
|
||||||
|
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
|
||||||
|
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
|
||||||
|
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
|
||||||
|
.eg-weekly-share::after { border: 0; }
|
||||||
|
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
|
||||||
|
|
||||||
|
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
|
||||||
|
.eg-stage-card { width: 100%; max-width: 620rpx; padding: 34rpx 28rpx 30rpx; border: 6rpx solid #fff; border-radius: 38rpx; background: linear-gradient(155deg, #f5fff9, #fff8da); box-shadow: 0 26rpx 70rpx rgba(10,56,41,.3); text-align: center; animation: egStageIn 1.8s cubic-bezier(.16,.86,.28,1) both; }
|
||||||
|
.eg-stage-kicker { display: block; color: #16815f; font-size: 23rpx; font-weight: 900; letter-spacing: 3rpx; }
|
||||||
|
.eg-stage-number { display: block; margin-top: 5rpx; color: #e47722; font-size: 48rpx; font-weight: 1000; line-height: 1.1; }
|
||||||
|
.eg-stage-title { display: block; margin-top: 7rpx; color: #194e3d; font-size: 38rpx; font-weight: 900; }
|
||||||
|
.eg-stage-foods { display: flex; justify-content: center; gap: 9rpx; margin-top: 22rpx; }
|
||||||
|
.eg-stage-food { display: flex; width: 92rpx; min-height: 110rpx; flex-direction: column; align-items: center; justify-content: center; border: 3rpx solid #dbece4; border-radius: 20rpx; color: #315b4e; background: #fff; font-size: 20rpx; font-weight: 800; }
|
||||||
|
.eg-stage-food > image { width: 72rpx; height: 52rpx; border-radius: 12rpx; }
|
||||||
|
.eg-stage-food > text { margin-top: 8rpx; }
|
||||||
|
.eg-stage-food.is-danger { border-color: #ef8b52; color: #a43c20; background: #fff1e6; box-shadow: 0 0 0 4rpx rgba(239,139,82,.12); }
|
||||||
|
.eg-stage-tip { display: block; margin-top: 20rpx; color: #6e776d; font-size: 21rpx; font-weight: 700; }
|
||||||
|
.eg-reshuffle-overlay { position: fixed; z-index: 65; inset: 0; display: flex; align-items: center; justify-content: center; padding: 46rpx; background: rgba(19,55,45,.5); }
|
||||||
|
.eg-reshuffle-card { width: 100%; max-width: 540rpx; padding: 38rpx 30rpx; border: 5rpx solid rgba(255,255,255,.95); border-radius: 36rpx; background: linear-gradient(155deg, #f4fff9, #e6f6ef); box-shadow: 0 24rpx 64rpx rgba(10,48,37,.3); text-align: center; }
|
||||||
|
.eg-reshuffle-icon { display: flex; width: 94rpx; height: 94rpx; align-items: center; justify-content: center; margin: 0 auto 18rpx; border-radius: 50%; background: linear-gradient(135deg, #2aa87b, #126a50); box-shadow: 0 10rpx 24rpx rgba(18,106,80,.28); animation: egReshuffleSpin .85s linear infinite; }
|
||||||
|
.eg-reshuffle-title { display: block; color: #174e3d; font-size: 36rpx; font-weight: 900; }
|
||||||
|
.eg-reshuffle-desc { display: block; margin-top: 10rpx; color: #5b746b; font-size: 24rpx; font-weight: 700; }
|
||||||
|
|
||||||
|
.eg-overlay { position: fixed; z-index: 50; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; padding: 36rpx; background: rgba(20, 48, 40, .62); }
|
||||||
|
.eg-modal { width: 100%; max-width: 650rpx; padding: 34rpx 28rpx 28rpx; border-radius: 38rpx; background: #fffdf8; box-shadow: 0 28rpx 70rpx rgba(0,0,0,.22); }
|
||||||
|
.eg-modal-icon { display: block; font-size: 68rpx; text-align: center; }
|
||||||
|
.eg-modal-title { display: block; margin-top: 6rpx; color: #204e3d; font-size: 38rpx; font-weight: 900; text-align: center; }
|
||||||
|
.eg-modal-sub { display: block; margin: 8rpx 0 22rpx; color: #718078; font-size: 24rpx; text-align: center; }
|
||||||
|
.eg-result-warning { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; margin: 0 auto; border-radius: 50%; color: #fff; background: #e4572e; font-size: 60rpx; font-weight: 900; }
|
||||||
|
.eg-result-food { display: block; margin: 12rpx 0 18rpx; color: #c34b27; font-size: 28rpx; font-weight: 800; text-align: center; }
|
||||||
|
.eg-knowledge-card { padding: 22rpx; border-radius: 24rpx; background: #fff3e7; }
|
||||||
|
.eg-knowledge-title { display: block; color: #8d4427; font-size: 26rpx; font-weight: 800; }
|
||||||
|
.eg-knowledge-copy { display: block; margin-top: 9rpx; color: #5f514a; font-size: 23rpx; line-height: 1.65; }
|
||||||
|
.eg-knowledge-review { display: block; margin-top: 12rpx; color: #9b7768; font-size: 19rpx; }
|
||||||
|
.eg-result-score { display: flex; align-items: center; justify-content: space-between; margin: 20rpx 6rpx; color: #466258; font-size: 25rpx; }
|
||||||
|
.eg-result-score text:last-child { color: #e16f24; font-size: 36rpx; font-weight: 900; }
|
||||||
|
.eg-result-best { display: flex; align-items: center; justify-content: space-between; margin: -12rpx 6rpx 20rpx; color: #75867f; font-size: 22rpx; }
|
||||||
|
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
|
||||||
|
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
|
||||||
|
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
|
||||||
|
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
|
||||||
|
.eg-share-btn::after { border: 0; }
|
||||||
|
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
|
||||||
|
@keyframes egPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
|
||||||
|
50% { box-shadow: 0 0 0 8rpx rgba(245,158,11,.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egWeeklyIn {
|
||||||
|
0% { opacity: 0; transform: translateY(34rpx) scale(.91); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egHit {
|
||||||
|
0% { opacity: 1; transform: scale(1); }
|
||||||
|
42% { opacity: 1; transform: scale(1.18) rotate(-3deg); }
|
||||||
|
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egVillainPeek {
|
||||||
|
0%, 72%, 100% { transform: translateY(0) rotate(0); filter: saturate(1.08) contrast(1.05); }
|
||||||
|
80% { transform: translateY(-3rpx) rotate(-1.5deg); filter: saturate(1.15) contrast(1.08); }
|
||||||
|
88% { transform: translateY(0) rotate(1.5deg); }
|
||||||
|
95% { transform: translateY(-2rpx) rotate(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egTaskRewardFlight {
|
||||||
|
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
|
||||||
|
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
|
||||||
|
72% { opacity: 1; transform: translate3d(270rpx, 900rpx, 0) scale(.68) rotate(8deg); }
|
||||||
|
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egNavNotice {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, -42%); }
|
||||||
|
12% { opacity: .9; transform: translate(-50%, -50%); }
|
||||||
|
42% { opacity: .82; transform: translate(-50%, -50%); }
|
||||||
|
100% { opacity: 0; transform: translate(-50%, -58%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egRewardGlow {
|
||||||
|
from { opacity: .4; transform: scale(.86); }
|
||||||
|
to { opacity: .9; transform: scale(1.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFireSweep {
|
||||||
|
0% { opacity: 0; transform: translate3d(-38%, 20rpx, 0) scaleX(.6); }
|
||||||
|
28% { opacity: 1; }
|
||||||
|
72% { opacity: .96; }
|
||||||
|
100% { opacity: 0; transform: translate3d(58%, -15rpx, 0) scaleX(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFireSpark {
|
||||||
|
0% { opacity: 0; transform: translateY(14rpx) scale(.5) rotate(35deg); }
|
||||||
|
38% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translateY(-38rpx) scale(1.05) rotate(58deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFireGlow {
|
||||||
|
0% { opacity: 0; transform: scale(.72); }
|
||||||
|
38% { opacity: 1; transform: scale(1.08); }
|
||||||
|
100% { opacity: 0; transform: scale(1.28); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFireCellHit {
|
||||||
|
0% { opacity: 1; transform: scale(1); }
|
||||||
|
24% { opacity: 1; transform: scale(1.09); }
|
||||||
|
72% { opacity: 1; transform: scale(1.02); }
|
||||||
|
100% { opacity: 0; transform: scale(.48); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFireFlame {
|
||||||
|
0% { opacity: 0; transform: translateY(20rpx) rotate(42deg) scale(.55); }
|
||||||
|
38% { opacity: 1; transform: translateY(0) rotate(42deg) scale(1.05); }
|
||||||
|
100% { opacity: 0; transform: translateY(-31rpx) rotate(48deg) scale(.86); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFlameReady {
|
||||||
|
from { transform: scale(.94); filter: brightness(.96); }
|
||||||
|
to { transform: scale(1.08); filter: brightness(1.18); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egLReady {
|
||||||
|
from { transform: scale(.94) rotate(-2deg); filter: brightness(.96); }
|
||||||
|
to { transform: scale(1.06) rotate(2deg); filter: brightness(1.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egImpact {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, -42%) scale(.55); }
|
||||||
|
35% { opacity: 1; transform: translate(-50%, -50%) scale(1.16); }
|
||||||
|
72% { opacity: 1; transform: translate(-50%, -54%) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translate(-50%, -76%) scale(.92); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egJackpotIn {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, -45%) scale(.48) rotate(-4deg); }
|
||||||
|
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.09) rotate(2deg); }
|
||||||
|
31% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
|
||||||
|
78% { opacity: 1; transform: translate(-50%, -53%) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translate(-50%, -68%) scale(.92); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egTripleIn {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, -44%) scale(.82); }
|
||||||
|
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
|
||||||
|
74% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translate(-50%, -60%) scale(.96); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egTripleCan {
|
||||||
|
0% { opacity: 0; transform: translateY(18rpx) scale(.72); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFourIn {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, -44%) scale(.62) rotate(-3deg); }
|
||||||
|
18% { opacity: 1; transform: translate(-50%, -50%) scale(1.08) rotate(1deg); }
|
||||||
|
29% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
|
||||||
|
78% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
|
||||||
|
100% { opacity: 0; transform: translate(-50%, -65%) scale(.94); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFourCan {
|
||||||
|
0% { opacity: 0; transform: translateY(-22rpx) rotate(-7deg) scale(.65); }
|
||||||
|
75% { opacity: 1; transform: translateY(5rpx) rotate(2deg) scale(1.05); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFiveCan {
|
||||||
|
0% { opacity: 0; transform: translateY(-40rpx) scale(.52) rotate(-8deg); filter: brightness(1.5); }
|
||||||
|
70% { opacity: 1; transform: translateY(7rpx) scale(1.08) rotate(2deg); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0); filter: brightness(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egLockedCan {
|
||||||
|
from { opacity: .62; transform: scale(.92); filter: brightness(.9); }
|
||||||
|
to { opacity: 1; transform: scale(1.06); filter: brightness(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egPromisePulse {
|
||||||
|
from { transform: scale(.97); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); }
|
||||||
|
to { transform: scale(1.03); box-shadow: 0 7rpx 21rpx rgba(255,221,89,.38); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egReelStop {
|
||||||
|
0% { opacity: .2; transform: translateY(-48rpx) scaleY(1.4); filter: blur(5rpx); }
|
||||||
|
70% { opacity: 1; transform: translateY(7rpx) scaleY(.94); filter: blur(0); }
|
||||||
|
100% { transform: translateY(0) scaleY(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egLights {
|
||||||
|
0% { opacity: .35; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egConfettiFall {
|
||||||
|
0% { opacity: 0; transform: translate3d(0, -8vh, 0) rotate(0deg) scale(.7); }
|
||||||
|
9% { opacity: 1; }
|
||||||
|
48% { transform: translate3d(42rpx, 48vh, 0) rotate(420deg) scale(1); }
|
||||||
|
100% { opacity: .92; transform: translate3d(-28rpx, 112vh, 0) rotate(920deg) scale(.86); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egPointsPop {
|
||||||
|
0% { opacity: 0; transform: scale(.35) rotate(-7deg); }
|
||||||
|
55% { opacity: 1; transform: scale(1.28) rotate(3deg); }
|
||||||
|
78% { transform: scale(.92) rotate(0); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFlowShell {
|
||||||
|
from { filter: saturate(1); }
|
||||||
|
to { filter: saturate(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egFlowBadge {
|
||||||
|
from { transform: scale(.98); filter: brightness(.96); }
|
||||||
|
to { transform: scale(1.035); filter: brightness(1.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egStageIn {
|
||||||
|
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
|
||||||
|
18% { opacity: 1; transform: scale(1.06) translateY(0); }
|
||||||
|
30%, 76% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(.96) translateY(-22rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes egReshuffleSpin {
|
||||||
|
from { transform: rotate(0); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-height: 760px) {
|
||||||
|
.eg-task-card { padding-top: 13rpx; padding-bottom: 13rpx; }
|
||||||
|
}
|
||||||
@@ -419,7 +419,7 @@
|
|||||||
|
|
||||||
<view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }">
|
<view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }">
|
||||||
<view class="ai-diet-section-head">
|
<view class="ai-diet-section-head">
|
||||||
<text class="ai-diet-section-title">今日三餐</text>
|
<text class="ai-diet-section-title">今日饮食</text>
|
||||||
<text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中…</text>
|
<text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中…</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="ai-diet-meal-list">
|
<view class="ai-diet-meal-list">
|
||||||
@@ -434,6 +434,20 @@
|
|||||||
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
|
||||||
|
class="ai-diet-meal-card meal-drinks"
|
||||||
|
>
|
||||||
|
<view class="ai-diet-meal-head">
|
||||||
|
<view class="ai-diet-meal-icon" aria-hidden="true">
|
||||||
|
<TongjiIcon name="droplet" size="sm" color="#ffffff" />
|
||||||
|
</view>
|
||||||
|
<text class="ai-diet-meal-label">喝的</text>
|
||||||
|
</view>
|
||||||
|
<text class="ai-diet-meal-text">
|
||||||
|
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor">▍</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
<view class="ai-diet-meal-card meal-lunch">
|
<view class="ai-diet-meal-card meal-lunch">
|
||||||
<view class="ai-diet-meal-head">
|
<view class="ai-diet-meal-head">
|
||||||
<view class="ai-diet-meal-icon" aria-hidden="true">
|
<view class="ai-diet-meal-icon" aria-hidden="true">
|
||||||
@@ -2657,16 +2671,13 @@ async function bootstrap(options) {
|
|||||||
if (!target) target = list[0]
|
if (!target) target = list[0]
|
||||||
await applyCard(target)
|
await applyCard(target)
|
||||||
} else {
|
} else {
|
||||||
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
|
diagnosisId.value = 0
|
||||||
if (passedPatientId) patientId.value = passedPatientId
|
patientId.value = 0
|
||||||
if (passedPatientName) patientName.value = passedPatientName
|
patientName.value = ''
|
||||||
if (passedAge) age.value = passedAge
|
age.value = 0
|
||||||
if (passedGender) gender.value = passedGender
|
gender.value = 0
|
||||||
if (!diagnosisId.value || !patientId.value) {
|
|
||||||
readUserContext()
|
|
||||||
}
|
|
||||||
readUserMobile()
|
readUserMobile()
|
||||||
if (!diagnosisId.value && userMobile.value) {
|
if (userMobile.value) {
|
||||||
await ensureDailyContextByPhone()
|
await ensureDailyContextByPhone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6558,6 +6569,12 @@ async function onFamilyLike() {
|
|||||||
.ai-diet-meal-card.meal-breakfast::before {
|
.ai-diet-meal-card.meal-breakfast::before {
|
||||||
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
|
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
}
|
}
|
||||||
|
.ai-diet-meal-card.meal-drinks {
|
||||||
|
background: linear-gradient(120deg, rgba(14, 165, 233, 0.14) 0%, rgba(255, 255, 255, 0) 45%);
|
||||||
|
}
|
||||||
|
.ai-diet-meal-card.meal-drinks::before {
|
||||||
|
background: linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
|
||||||
|
}
|
||||||
.ai-diet-meal-card.meal-lunch {
|
.ai-diet-meal-card.meal-lunch {
|
||||||
background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%);
|
background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%);
|
||||||
}
|
}
|
||||||
@@ -6588,6 +6605,9 @@ async function onFamilyLike() {
|
|||||||
.meal-breakfast .ai-diet-meal-icon {
|
.meal-breakfast .ai-diet-meal-icon {
|
||||||
background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%);
|
background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%);
|
||||||
}
|
}
|
||||||
|
.meal-drinks .ai-diet-meal-icon {
|
||||||
|
background: linear-gradient(150deg, #7dd3fc 0%, #0ea5e9 100%);
|
||||||
|
}
|
||||||
.meal-lunch .ai-diet-meal-icon {
|
.meal-lunch .ai-diet-meal-icon {
|
||||||
background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%);
|
background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,19 +45,21 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<view class="st-main">
|
<view class="st-main">
|
||||||
<!-- 1. 录入今日血糖 -->
|
<!-- 快捷建档失败提示(toast 字数有限,完整文案展示在页面) -->
|
||||||
<view class="st-cta" @click="openInputForm()">
|
<view v-if="dailyContextError && !diagnosisId" class="st-daily-context-error" role="alert">
|
||||||
<TongjiIcon name="plus" size="md" color="#FFFFFF" />
|
<text class="st-daily-context-error-text">{{ dailyContextError }}</text>
|
||||||
<text>录入今日血糖</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 2. 血糖趋势 -->
|
<!-- 2. 血糖趋势(无就诊卡或累计录入不足 7 天显示示例曲线) -->
|
||||||
<view v-if="diagnosisId" class="st-chart-card">
|
<view class="st-chart-card">
|
||||||
<view class="st-chart-head">
|
<view class="st-chart-head">
|
||||||
<view>
|
<view style="padding: 0 20rpx;">
|
||||||
<text class="st-chart-title">血糖趋势</text>
|
<text class="st-chart-title">血糖趋势</text>
|
||||||
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
|
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
|
||||||
|
|
||||||
</view>
|
</view>
|
||||||
<view class="st-chart-legend">
|
<view class="st-chart-legend">
|
||||||
<view
|
<view
|
||||||
@@ -78,82 +80,41 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="bloodHasData" class="st-chart-summary">
|
<view class="st-chart-area" @click="onChartTap">
|
||||||
<view class="st-chart-summary-item">
|
|
||||||
<text class="st-chart-summary-num is-high">{{ bloodHighDayCount }}</text>
|
|
||||||
<text class="st-chart-summary-label">天偏高</text>
|
|
||||||
</view>
|
|
||||||
<view class="st-chart-summary-item">
|
|
||||||
<text class="st-chart-summary-num is-good">{{ bloodNormalDayCount }}</text>
|
|
||||||
<text class="st-chart-summary-label">天正常</text>
|
|
||||||
</view>
|
|
||||||
<view class="st-chart-summary-item">
|
|
||||||
<text class="st-chart-summary-num">{{ bloodRecordDayCount }}</text>
|
|
||||||
<text class="st-chart-summary-label">天有记录</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="st-chart-area">
|
|
||||||
<canvas
|
<canvas
|
||||||
v-if="!recordOverlayOpen"
|
v-if="!recordOverlayOpen"
|
||||||
id="bloodWaveChart"
|
id="bloodWaveChart"
|
||||||
type="2d"
|
type="2d"
|
||||||
class="st-chart-canvas"
|
class="st-chart-canvas"
|
||||||
@touchstart="onChartTouch"
|
@click.stop="onChartTap"
|
||||||
@touchmove="onChartTouch"
|
|
||||||
/>
|
/>
|
||||||
<view v-if="recordOverlayOpen" class="st-chart-placeholder">
|
<view v-if="recordOverlayOpen" class="st-chart-placeholder">
|
||||||
<text>图表已暂停显示</text>
|
<text>图表已暂停显示</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="!bloodHasData && !loading && !recordOverlayOpen" class="st-chart-empty">
|
<view v-if="!chartHasDisplayData && !loading && !recordOverlayOpen" class="st-chart-empty">
|
||||||
<text>当前时间范围暂无血糖数据</text>
|
<text>当前时间范围暂无血糖数据</text>
|
||||||
</view>
|
</view>
|
||||||
<cover-view v-if="hoverInfo && !recordOverlayOpen" class="chart-tip" :style="{ left: hoverInfo.left + 'px', top: hoverInfo.top + 'px' }">
|
|
||||||
<cover-view class="chart-tip-date">{{ hoverInfo.date }}</cover-view>
|
|
||||||
<cover-view v-if="hoverInfo.fasting != null" class="chart-tip-row">
|
|
||||||
<cover-view class="st-dot st-dot-fasting" />
|
|
||||||
<cover-view>空腹 {{ hoverInfo.fasting }}</cover-view>
|
|
||||||
</cover-view>
|
|
||||||
<cover-view v-if="hoverInfo.postprandial != null" class="chart-tip-row">
|
|
||||||
<cover-view class="st-dot st-dot-post" />
|
|
||||||
<cover-view>餐后 {{ hoverInfo.postprandial }}</cover-view>
|
|
||||||
</cover-view>
|
|
||||||
</cover-view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="chartUseMockData" class="st-chart-foot">
|
||||||
|
<text class="st-chart-mock-hint">示例数据,累计记录满 7 天后显示您的真实趋势</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="st-game-entry-list">
|
||||||
<!-- 3. 最新测量 & 概览 -->
|
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
|
||||||
<view class="st-latest-section">
|
<view class="st-game-entry-icon">
|
||||||
<view
|
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||||||
v-if="latestMeasurement.hasValue"
|
|
||||||
class="st-latest-hero"
|
|
||||||
:class="{ 'is-high': latestMeasurement.isHigh }"
|
|
||||||
>
|
|
||||||
<view class="st-latest-hero-pattern" aria-hidden="true" />
|
|
||||||
<text class="st-latest-label">最新测量 · {{ latestMeasurement.typeLabel }}</text>
|
|
||||||
<view class="st-latest-value-row">
|
|
||||||
<text class="st-latest-value">{{ latestMeasurement.value }}</text>
|
|
||||||
<text class="st-latest-unit">mmol/L</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view v-if="latestMeasurement.isHigh" class="st-latest-flag">
|
<view class="st-game-entry-body">
|
||||||
<TongjiIcon name="alert-triangle" size="sm" color="#93000a" />
|
<text class="st-game-entry-title">识糖小课堂</text>
|
||||||
<text>偏高,请遵医嘱</text>
|
<text class="st-game-entry-sub">三消认食物,学习哪些食物含糖高</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||||
<view class="st-summary-grid">
|
|
||||||
<view class="st-summary-card">
|
|
||||||
<text class="st-summary-label">空腹</text>
|
|
||||||
<text class="st-summary-value" :class="{ 'is-high': elderFasting.isHigh }">{{ elderFasting.value }}</text>
|
|
||||||
<text v-if="elderFasting.isHigh" class="st-summary-tag">偏高</text>
|
|
||||||
</view>
|
|
||||||
<view class="st-summary-card">
|
|
||||||
<text class="st-summary-label">餐后</text>
|
|
||||||
<text class="st-summary-value" :class="{ 'is-high': elderPostprandial.isHigh }">{{ elderPostprandial.value }}</text>
|
|
||||||
<text v-if="elderPostprandial.isHigh" class="st-summary-tag">偏高</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="st-training-section">
|
||||||
<!-- 4. AI 饮食建议 -->
|
<dev-training-entry inline tone="weekly" />
|
||||||
|
</view>
|
||||||
|
<!-- 3. AI 饮食建议 -->
|
||||||
<view v-if="diagnosisId" class="st-ai-section">
|
<view v-if="diagnosisId" class="st-ai-section">
|
||||||
<view class="st-ai-deco" aria-hidden="true" />
|
<view class="st-ai-deco" aria-hidden="true" />
|
||||||
<view class="st-ai-head">
|
<view class="st-ai-head">
|
||||||
@@ -172,7 +133,7 @@
|
|||||||
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
|
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="st-ai-sub">读您近 7 天 · 30 天血糖,定制今日三餐</text>
|
<text class="st-ai-sub">读您近 7 天 · 30 天血糖,定制早餐(含驼奶粉)、喝的与三餐</text>
|
||||||
|
|
||||||
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="st-skeleton">
|
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="st-skeleton">
|
||||||
<view v-for="n in 2" :key="'sk-' + n" class="st-skeleton-line" />
|
<view v-for="n in 2" :key="'sk-' + n" class="st-skeleton-line" />
|
||||||
@@ -188,6 +149,18 @@
|
|||||||
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
|
||||||
|
class="st-meal-glass st-meal-glass--drinks"
|
||||||
|
>
|
||||||
|
<view class="st-meal-head">
|
||||||
|
<TongjiIcon name="droplet" size="sm" color="#0ea5e9" />
|
||||||
|
<text class="st-meal-label">喝的</text>
|
||||||
|
</view>
|
||||||
|
<text class="st-meal-text">
|
||||||
|
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor">▍</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
<view class="st-meal-glass">
|
<view class="st-meal-glass">
|
||||||
<view class="st-meal-head">
|
<view class="st-meal-head">
|
||||||
<TongjiIcon name="leaf" size="sm" color="#006c49" />
|
<TongjiIcon name="leaf" size="sm" color="#006c49" />
|
||||||
@@ -212,22 +185,46 @@
|
|||||||
<view class="st-main-spacer" />
|
<view class="st-main-spacer" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 收起后的重新唤起按钮 -->
|
||||||
|
<view v-if="diagnosisId && floatAskClosed" class="st-float-reopen" @click="floatAskClosed = false">
|
||||||
|
<TongjiIcon name="sparkles" size="sm" color="#ffffff" />
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 底部浮动 AI 输入栏 -->
|
<!-- 底部浮动 AI 输入栏 -->
|
||||||
<view v-if="diagnosisId" class="st-float-ask">
|
<view v-if="diagnosisId && !floatAskClosed" class="st-float-ask">
|
||||||
<view class="st-float-inner">
|
<view class="st-float-close" @click="floatAskClosed = true">
|
||||||
|
<text class="st-float-close-icon">×</text>
|
||||||
|
</view>
|
||||||
|
<view class="st-float-inner" :class="{ 'is-expanded': dietAiAskExpanded }">
|
||||||
<view class="st-float-icon">
|
<view class="st-float-icon">
|
||||||
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||||||
</view>
|
</view>
|
||||||
<input
|
<textarea
|
||||||
class="st-float-input"
|
class="st-float-input"
|
||||||
type="text"
|
|
||||||
:value="dietAiAskText"
|
:value="dietAiAskText"
|
||||||
placeholder="输入食物名称或健康疑问,咨询 AI..."
|
placeholder="输入或语音咨询 AI…"
|
||||||
placeholder-class="st-float-placeholder"
|
placeholder-class="st-float-placeholder"
|
||||||
|
:maxlength="-1"
|
||||||
|
auto-height
|
||||||
|
:show-confirm-bar="false"
|
||||||
|
:disable-default-padding="true"
|
||||||
|
:cursor-spacing="24"
|
||||||
confirm-type="send"
|
confirm-type="send"
|
||||||
|
:confirm-hold="false"
|
||||||
@input="onDietAiAskInput"
|
@input="onDietAiAskInput"
|
||||||
@confirm="askDietAiFood"
|
@confirm="askDietAiFood"
|
||||||
|
@focus="onDietAiAskFocus"
|
||||||
|
@blur="onDietAiAskBlur"
|
||||||
/>
|
/>
|
||||||
|
<view
|
||||||
|
class="st-float-mic"
|
||||||
|
:class="{ listening: sttListening || sttHolding, disabled: dietAiAsking || !sttSupported }"
|
||||||
|
@touchstart.stop.prevent="onDietAiSpeechStart"
|
||||||
|
@touchend.stop.prevent="onDietAiSpeechEnd"
|
||||||
|
@touchcancel.stop.prevent="onDietAiSpeechEnd"
|
||||||
|
>
|
||||||
|
<TongjiIcon name="mic" size="sm" :color="(sttListening || sttHolding) ? '#ffffff' : '#006c49'" />
|
||||||
|
</view>
|
||||||
<view
|
<view
|
||||||
class="st-float-send"
|
class="st-float-send"
|
||||||
:class="{ disabled: dietAiAsking }"
|
:class="{ disabled: dietAiAsking }"
|
||||||
@@ -236,6 +233,9 @@
|
|||||||
<TongjiIcon name="send" size="sm" color="#ffffff" />
|
<TongjiIcon name="send" size="sm" color="#ffffff" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="sttHolding && !dietAiAsking" class="st-float-hold-hint">
|
||||||
|
<text>{{ sttListening ? '松手结束' : '准备录音…' }}</text>
|
||||||
|
</view>
|
||||||
<view v-if="dietAiAsking || dietAiAskResult.advice" class="st-float-result">
|
<view v-if="dietAiAsking || dietAiAskResult.advice" class="st-float-result">
|
||||||
<view v-if="dietAiAskResult.level_label" class="st-float-badge">
|
<view v-if="dietAiAskResult.level_label" class="st-float-badge">
|
||||||
<text>{{ dietAiAskResult.level_label }}</text>
|
<text>{{ dietAiAskResult.level_label }}</text>
|
||||||
@@ -246,6 +246,17 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 语音长按状态浮层:居中显示,避免被键盘/底栏遮挡 -->
|
||||||
|
<view v-if="sttHolding && !dietAiAsking" class="st-voice-overlay">
|
||||||
|
<view class="st-voice-card">
|
||||||
|
<view class="st-voice-icon" :class="{ active: sttListening }">
|
||||||
|
<TongjiIcon name="mic" size="lg" color="#ffffff" />
|
||||||
|
</view>
|
||||||
|
<text class="st-voice-title">{{ sttListening ? '正在聆听…' : '准备录音…' }}</text>
|
||||||
|
<text class="st-voice-sub">松开手指结束并识别</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 无就诊卡:授权手机号后可录入 -->
|
<!-- 无就诊卡:授权手机号后可录入 -->
|
||||||
<view v-if="phoneGateVisible" class="input-modal-mask" @click="closePhoneGate">
|
<view v-if="phoneGateVisible" class="input-modal-mask" @click="closePhoneGate">
|
||||||
<view class="input-modal phone-gate-modal" @click.stop>
|
<view class="input-modal phone-gate-modal" @click.stop>
|
||||||
@@ -260,6 +271,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="phone-gate-body">
|
<view class="phone-gate-body">
|
||||||
|
<view v-if="dailyContextError" class="phone-gate-error" role="alert">
|
||||||
|
<text>{{ dailyContextError }}</text>
|
||||||
|
</view>
|
||||||
<text class="phone-gate-tip">微信仅提供手机号,请选择性别以便写入就诊档案。</text>
|
<text class="phone-gate-tip">微信仅提供手机号,请选择性别以便写入就诊档案。</text>
|
||||||
<view class="phone-gate-gender">
|
<view class="phone-gate-gender">
|
||||||
<text class="phone-gate-gender-label">性别</text>
|
<text class="phone-gate-gender-label">性别</text>
|
||||||
@@ -295,7 +309,7 @@
|
|||||||
|
|
||||||
<!-- 录入今日数据弹层 -->
|
<!-- 录入今日数据弹层 -->
|
||||||
<view v-if="inputForm.visible" class="input-modal-mask" @click="closeInputForm">
|
<view v-if="inputForm.visible" class="input-modal-mask" @click="closeInputForm">
|
||||||
<view class="input-modal" @click.stop>
|
<view class="input-modal" :style="keyboardHeight ? { marginBottom: keyboardHeight + 'px' } : {}" @click.stop>
|
||||||
<view class="input-modal-grip" />
|
<view class="input-modal-grip" />
|
||||||
<view class="input-modal-head">
|
<view class="input-modal-head">
|
||||||
<view class="input-modal-title-wrap">
|
<view class="input-modal-title-wrap">
|
||||||
@@ -324,6 +338,7 @@
|
|||||||
<view class="input-field-input">
|
<view class="input-field-input">
|
||||||
<input
|
<input
|
||||||
type="digit"
|
type="digit"
|
||||||
|
:adjust-position="false"
|
||||||
:value="inputForm.fasting_blood_sugar"
|
:value="inputForm.fasting_blood_sugar"
|
||||||
placeholder="如 6.5"
|
placeholder="如 6.5"
|
||||||
placeholder-class="input-placeholder"
|
placeholder-class="input-placeholder"
|
||||||
@@ -339,6 +354,7 @@
|
|||||||
<view class="input-field-input">
|
<view class="input-field-input">
|
||||||
<input
|
<input
|
||||||
type="digit"
|
type="digit"
|
||||||
|
:adjust-position="false"
|
||||||
:value="inputForm.postprandial_blood_sugar"
|
:value="inputForm.postprandial_blood_sugar"
|
||||||
placeholder="如 8.0"
|
placeholder="如 8.0"
|
||||||
placeholder-class="input-placeholder"
|
placeholder-class="input-placeholder"
|
||||||
@@ -354,6 +370,7 @@
|
|||||||
<view class="input-field-input">
|
<view class="input-field-input">
|
||||||
<input
|
<input
|
||||||
type="digit"
|
type="digit"
|
||||||
|
:adjust-position="false"
|
||||||
:value="inputForm.other_blood_sugar"
|
:value="inputForm.other_blood_sugar"
|
||||||
placeholder="如 7.2"
|
placeholder="如 7.2"
|
||||||
placeholder-class="input-placeholder"
|
placeholder-class="input-placeholder"
|
||||||
@@ -370,6 +387,7 @@
|
|||||||
placeholder-class="input-placeholder"
|
placeholder-class="input-placeholder"
|
||||||
:show-confirm-bar="false"
|
:show-confirm-bar="false"
|
||||||
:auto-height="true"
|
:auto-height="true"
|
||||||
|
:adjust-position="false"
|
||||||
@input="onInputField('remark', $event)"
|
@input="onInputField('remark', $event)"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
@@ -395,6 +413,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
|
<TabBarDock :active="1" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -404,6 +423,9 @@ import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage
|
|||||||
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
||||||
import TongjiIcon from '../components/TongjiIcon.vue'
|
import TongjiIcon from '../components/TongjiIcon.vue'
|
||||||
import { useDietAi } from '../composables/useDietAi.js'
|
import { useDietAi } from '../composables/useDietAi.js'
|
||||||
|
import { useSpeechToText } from '../composables/useSpeechToText.js'
|
||||||
|
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
|
||||||
|
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
@@ -436,11 +458,42 @@ function showUserToast(title, options = {}) {
|
|||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: text,
|
title: text,
|
||||||
icon: options.icon || 'none',
|
icon: options.icon || 'none',
|
||||||
duration: options.duration
|
duration: options.duration || 2800
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析接口错误文案(兼容 msg / message / data.error) */
|
||||||
|
function extractApiError(res, fallback = '') {
|
||||||
|
if (!res) return fallback
|
||||||
|
const raw = res.msg ?? res.message ?? res.data?.error ?? res.data?.msg ?? res.errMsg
|
||||||
|
return formatUserMessage(raw, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
const diagnosisId = ref(0)
|
const diagnosisId = ref(0)
|
||||||
|
/** 手机号快捷建档失败:页面常驻提示(避免 toast 被 7 字截断) */
|
||||||
|
const dailyContextError = ref('')
|
||||||
|
|
||||||
|
function clearDailyContextError() {
|
||||||
|
dailyContextError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportDailyContextError(res, fallback = '无法开始录入', options = {}) {
|
||||||
|
const text = extractApiError(res, fallback)
|
||||||
|
if (!text) return
|
||||||
|
dailyContextError.value = text
|
||||||
|
if (options.alert) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '无法录入血糖',
|
||||||
|
content: text,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const short = text.length > 14 ? `${text.slice(0, 14)}…` : text
|
||||||
|
showUserToast(short, { duration: 3200 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const patientId = ref(0)
|
const patientId = ref(0)
|
||||||
const patientName = ref('')
|
const patientName = ref('')
|
||||||
const displayPatientName = computed(() => {
|
const displayPatientName = computed(() => {
|
||||||
@@ -472,6 +525,52 @@ const {
|
|||||||
openDietForm
|
openDietForm
|
||||||
} = useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage })
|
} = useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage })
|
||||||
|
|
||||||
|
/** 底部 AI 浮动卡片是否被用户收起 */
|
||||||
|
const floatAskClosed = ref(false)
|
||||||
|
|
||||||
|
/** AI 输入框是否聚焦:聚焦时输入框变大 */
|
||||||
|
const dietAiAskFocused = ref(false)
|
||||||
|
function onDietAiAskFocus() {
|
||||||
|
dietAiAskFocused.value = true
|
||||||
|
}
|
||||||
|
function onDietAiAskBlur() {
|
||||||
|
dietAiAskFocused.value = false
|
||||||
|
}
|
||||||
|
/** 聚焦或已有内容时展开为多行输入(内容多则自动增高) */
|
||||||
|
const dietAiAskExpanded = computed(
|
||||||
|
() => dietAiAskFocused.value || String(dietAiAskText.value || '').length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 键盘高度(px):录入弹窗据此整体上移,避免输入框被键盘遮挡 */
|
||||||
|
const keyboardHeight = ref(0)
|
||||||
|
function onKeyboardHeightChange(res) {
|
||||||
|
keyboardHeight.value = res && res.height ? Math.round(res.height) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
sttListening,
|
||||||
|
sttHolding,
|
||||||
|
sttSupported,
|
||||||
|
beginHoldSpeech,
|
||||||
|
endHoldSpeech,
|
||||||
|
stopSpeechToText
|
||||||
|
} = useSpeechToText({
|
||||||
|
onResult(text) {
|
||||||
|
const prev = String(dietAiAskText.value || '').trim()
|
||||||
|
dietAiAskText.value = prev ? `${prev} ${text}` : text
|
||||||
|
},
|
||||||
|
showToast: (msg) => showUserToast(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onDietAiSpeechStart() {
|
||||||
|
if (dietAiAsking.value) return
|
||||||
|
beginHoldSpeech()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDietAiSpeechEnd() {
|
||||||
|
endHoldSpeech()
|
||||||
|
}
|
||||||
|
|
||||||
/** AI 饮食建议依据:近7天 / 近30天 血糖统计 */
|
/** AI 饮食建议依据:近7天 / 近30天 血糖统计 */
|
||||||
const dietAnalysisList = computed(() => {
|
const dietAnalysisList = computed(() => {
|
||||||
const list = dietAiRecommend.value && Array.isArray(dietAiRecommend.value.analysis)
|
const list = dietAiRecommend.value && Array.isArray(dietAiRecommend.value.analysis)
|
||||||
@@ -603,6 +702,19 @@ const currentRange = computed(() => {
|
|||||||
return { start: formatDate(start), end: formatDate(end) }
|
return { start: formatDate(start), end: formatDate(end) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 拉取血糖时向前多看几天,用于判断「累计满 7 天」是否达标(图表仍只画 currentRange) */
|
||||||
|
const BLOOD_LOOKBACK_DAYS = 30
|
||||||
|
|
||||||
|
const fetchRange = computed(() => {
|
||||||
|
const displayDays = rangeMode.value === '30' ? 30 : 7
|
||||||
|
const lookback = Math.max(BLOOD_LOOKBACK_DAYS, displayDays)
|
||||||
|
const end = new Date()
|
||||||
|
end.setHours(0, 0, 0, 0)
|
||||||
|
const start = new Date(end)
|
||||||
|
start.setDate(start.getDate() - (lookback - 1))
|
||||||
|
return { start: formatDate(start), end: formatDate(end) }
|
||||||
|
})
|
||||||
|
|
||||||
const visibleDates = computed(() => {
|
const visibleDates = computed(() => {
|
||||||
const { start, end } = currentRange.value
|
const { start, end } = currentRange.value
|
||||||
if (!start || !end) return []
|
if (!start || !end) return []
|
||||||
@@ -765,18 +877,73 @@ const recordDays = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function bloodDayHasSugar(b) {
|
||||||
|
if (!b) return false
|
||||||
|
return toNumber(b.fasting_blood_sugar) !== null
|
||||||
|
|| toNumber(b.postprandial_blood_sugar) !== null
|
||||||
|
|| toNumber(b.other_blood_sugar) !== null
|
||||||
|
}
|
||||||
|
|
||||||
const bloodHasData = computed(() => {
|
const bloodHasData = computed(() => {
|
||||||
return chartDates.value.some((date) => {
|
return chartDates.value.some((date) => bloodDayHasSugar(bloodByDate.value[date]))
|
||||||
const b = bloodByDate.value[date]
|
|
||||||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 当前图表时间范围内有真实录入的天数(用于小结统计) */
|
||||||
const bloodRecordDayCount = computed(() => {
|
const bloodRecordDayCount = computed(() => {
|
||||||
return chartDates.value.filter((date) => {
|
return chartDates.value.filter((date) => bloodDayHasSugar(bloodByDate.value[date])).length
|
||||||
const b = bloodByDate.value[date]
|
})
|
||||||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
|
||||||
}).length
|
/** 拉取窗口内累计有血糖录入的天数(不要求连续) */
|
||||||
|
const bloodCumulativeDayCount = computed(() => {
|
||||||
|
let n = 0
|
||||||
|
for (const date of Object.keys(bloodByDate.value)) {
|
||||||
|
if (bloodDayHasSugar(bloodByDate.value[date])) n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
|
||||||
|
const REAL_BLOOD_DAYS_MIN = 7
|
||||||
|
|
||||||
|
/** 无就诊卡或累计录入不足 7 天时,图表用示例数据 */
|
||||||
|
const chartUseMockData = computed(() => {
|
||||||
|
if (!diagnosisId.value) return true
|
||||||
|
return bloodCumulativeDayCount.value < REAL_BLOOD_DAYS_MIN
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 示例曲线:7 天内有波动的空腹/餐后,便于老人理解趋势图形态 */
|
||||||
|
const mockBloodByDate = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
const dates = chartDates.value
|
||||||
|
dates.forEach((date, i) => {
|
||||||
|
const t = dates.length > 1 ? i / (dates.length - 1) : 0
|
||||||
|
const fasting = Number((5.2 + Math.sin(t * Math.PI) * 0.5 + (i % 2) * 0.15).toFixed(1))
|
||||||
|
const post = Number((7.6 + Math.cos(t * Math.PI) * 1.1 + (i % 3) * 0.25).toFixed(1))
|
||||||
|
const row = {
|
||||||
|
record_date: date,
|
||||||
|
fasting_blood_sugar: fasting,
|
||||||
|
postprandial_blood_sugar: post,
|
||||||
|
other_blood_sugar: '',
|
||||||
|
systolic_pressure: '',
|
||||||
|
diastolic_pressure: ''
|
||||||
|
}
|
||||||
|
row.isHighFasting = isHighFasting(row.fasting_blood_sugar)
|
||||||
|
row.isHighPostprandial = isHighPostprandial(row.postprandial_blood_sugar)
|
||||||
|
row.isHighOther = false
|
||||||
|
row.isHighBp = false
|
||||||
|
row.bpText = '—'
|
||||||
|
map[date] = row
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 图表绘制用数据源:示例或真实 */
|
||||||
|
const chartBloodByDate = computed(() => (
|
||||||
|
chartUseMockData.value ? mockBloodByDate.value : bloodByDate.value
|
||||||
|
))
|
||||||
|
|
||||||
|
const chartHasDisplayData = computed(() => {
|
||||||
|
if (chartUseMockData.value) return chartDates.value.length > 0
|
||||||
|
return bloodHasData.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const bloodHighDayCount = computed(() => {
|
const bloodHighDayCount = computed(() => {
|
||||||
@@ -1080,8 +1247,14 @@ function syncPhoneGateSexFromUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPhoneGate() {
|
async function openPhoneGate() {
|
||||||
syncPhoneGateSexFromUser()
|
syncPhoneGateSexFromUser()
|
||||||
|
readUserMobile()
|
||||||
|
if (userMobile.value) {
|
||||||
|
phoneGateVisible.value = false
|
||||||
|
await resolveDailyPhoneContext(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
phoneGateVisible.value = true
|
phoneGateVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,8 +1271,11 @@ async function refreshUserMobile() {
|
|||||||
return userMobile.value
|
return userMobile.value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDailyContextByPhone(sex) {
|
async function ensureDailyContextByPhone(sex, options = {}) {
|
||||||
if (diagnosisId.value) return true
|
if (diagnosisId.value) {
|
||||||
|
clearDailyContextError()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {}
|
const payload = {}
|
||||||
const sexVal = sex ?? phoneGateSex.value
|
const sexVal = sex ?? phoneGateSex.value
|
||||||
@@ -1114,6 +1290,7 @@ async function ensureDailyContextByPhone(sex) {
|
|||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
if (res && res.code === 1 && res.data && res.data.diagnosis_id) {
|
if (res && res.code === 1 && res.data && res.data.diagnosis_id) {
|
||||||
|
clearDailyContextError()
|
||||||
await applyCard({
|
await applyCard({
|
||||||
id: res.data.diagnosis_id,
|
id: res.data.diagnosis_id,
|
||||||
patient_id: res.data.patient_id,
|
patient_id: res.data.patient_id,
|
||||||
@@ -1121,17 +1298,32 @@ async function ensureDailyContextByPhone(sex) {
|
|||||||
age: res.data.age || 0,
|
age: res.data.age || 0,
|
||||||
gender: res.data.gender || 0
|
gender: res.data.gender || 0
|
||||||
})
|
})
|
||||||
if (res.data.created) {
|
if (res.data.created || res.data.linked_existing) {
|
||||||
await fetchCardList()
|
await fetchCardList()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res && res.data && res.data.need_mobile) {
|
if (res && res.data && res.data.need_mobile) {
|
||||||
|
clearDailyContextError()
|
||||||
return 'need_mobile'
|
return 'need_mobile'
|
||||||
}
|
}
|
||||||
|
|
||||||
showUserToast(formatUserMessage(res?.msg, '无法开始录入'))
|
reportDailyContextError(res, '无法开始录入', options)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已绑定手机号时直接关联档案,不再弹出授权建档 */
|
||||||
|
async function resolveDailyPhoneContext(openFormAfter = false) {
|
||||||
|
const ok = await ensureDailyContextByPhone(phoneGateSex.value, { alert: true })
|
||||||
|
if (ok === true) {
|
||||||
|
if (openFormAfter) await openInputForm(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (ok === 'need_mobile') {
|
||||||
|
syncPhoneGateSexFromUser()
|
||||||
|
phoneGateVisible.value = true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,7 +1336,7 @@ async function ensureCanRecordGlucose() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await ensureDailyContextByPhone()
|
const ok = await ensureDailyContextByPhone(undefined, { alert: true })
|
||||||
if (ok === 'need_mobile') {
|
if (ok === 'need_mobile') {
|
||||||
openPhoneGate()
|
openPhoneGate()
|
||||||
return false
|
return false
|
||||||
@@ -1177,6 +1369,13 @@ async function onPhoneGateAuthorize(e) {
|
|||||||
|
|
||||||
phoneBinding.value = true
|
phoneBinding.value = true
|
||||||
try {
|
try {
|
||||||
|
readUserMobile()
|
||||||
|
if (userMobile.value) {
|
||||||
|
phoneGateVisible.value = false
|
||||||
|
await resolveDailyPhoneContext(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const res = await proxy.apiUrl({
|
const res = await proxy.apiUrl({
|
||||||
url: '/api/user/getMobileByMnp',
|
url: '/api/user/getMobileByMnp',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1188,12 +1387,7 @@ async function onPhoneGateAuthorize(e) {
|
|||||||
}
|
}
|
||||||
await refreshUserMobile()
|
await refreshUserMobile()
|
||||||
phoneGateVisible.value = false
|
phoneGateVisible.value = false
|
||||||
const ok = await ensureDailyContextByPhone(phoneGateSex.value)
|
await resolveDailyPhoneContext(true)
|
||||||
if (ok === true) {
|
|
||||||
await openInputForm(true)
|
|
||||||
} else if (ok === 'need_mobile') {
|
|
||||||
openPhoneGate()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showUserToast('网络异常,请稍后再试')
|
showUserToast('网络异常,请稍后再试')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1216,7 +1410,7 @@ async function onSelectCard(card) {
|
|||||||
async function fetchAll() {
|
async function fetchAll() {
|
||||||
if (!hasAuthToken() || !diagnosisId.value) return
|
if (!hasAuthToken() || !diagnosisId.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const { start, end } = currentRange.value
|
const { start, end } = fetchRange.value
|
||||||
try {
|
try {
|
||||||
const [winRes, noteRes] = await Promise.all([
|
const [winRes, noteRes] = await Promise.all([
|
||||||
proxy.apiUrl({
|
proxy.apiUrl({
|
||||||
@@ -1290,16 +1484,51 @@ function onRefresh() {
|
|||||||
fetchAll()
|
fetchAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMorePageUrl() {
|
||||||
|
if (!diagnosisId.value) return '/tongji/pages/more'
|
||||||
|
const q = [
|
||||||
|
`diagnosis_id=${diagnosisId.value}`,
|
||||||
|
patientId.value ? `patient_id=${patientId.value}` : ''
|
||||||
|
].filter(Boolean).join('&')
|
||||||
|
return `/tongji/pages/more?${q}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击血糖趋势图:进入记录页 more(无就诊卡时先手机号快捷建档) */
|
||||||
|
async function onChartTap() {
|
||||||
|
if (!diagnosisId.value) {
|
||||||
|
const ok = await ensureCanRecordGlucose()
|
||||||
|
if (!ok || !diagnosisId.value) return
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: buildMorePageUrl() })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goRecordsPage() {
|
||||||
|
onChartTap()
|
||||||
|
}
|
||||||
|
|
||||||
async function goMorePage() {
|
async function goMorePage() {
|
||||||
if (!diagnosisId.value) {
|
if (!diagnosisId.value) {
|
||||||
const ok = await ensureCanRecordGlucose()
|
const ok = await ensureCanRecordGlucose()
|
||||||
if (!ok || !diagnosisId.value) return
|
if (!ok || !diagnosisId.value) return
|
||||||
}
|
}
|
||||||
uni.navigateTo({ url: '/tongji/pages/more' })
|
uni.navigateTo({ url: buildMorePageUrl() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGamePage(url) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url,
|
||||||
|
fail() {
|
||||||
|
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEndlessGamePage() {
|
||||||
|
openGamePage('/tongji/endless-game/index')
|
||||||
}
|
}
|
||||||
|
|
||||||
function navToUser() {
|
function navToUser() {
|
||||||
uni.switchTab({ url: '/pages/user/user' })
|
uni.redirectTo({ url: '/pages/user/user' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 时段问候 / 打卡 / 健康日历 ============
|
// ============ 时段问候 / 打卡 / 健康日历 ============
|
||||||
@@ -1994,7 +2223,7 @@ function scheduleChartRedraw() {
|
|||||||
|
|
||||||
function buildSeries(field) {
|
function buildSeries(field) {
|
||||||
return chartDates.value.map((date) => {
|
return chartDates.value.map((date) => {
|
||||||
const b = bloodByDate.value[date]
|
const b = chartBloodByDate.value[date]
|
||||||
return b ? toNumber(b[field]) : null
|
return b ? toNumber(b[field]) : null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2028,7 +2257,7 @@ function drawChart() {
|
|||||||
|
|
||||||
ctx.clearRect(0, 0, w, h)
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
const padding = { left: 40, right: 18, top: 26, bottom: 36 }
|
const padding = { left: 40, right: 18, top: 12, bottom: 28 }
|
||||||
const cw = w - padding.left - padding.right
|
const cw = w - padding.left - padding.right
|
||||||
const ch = h - padding.top - padding.bottom
|
const ch = h - padding.top - padding.bottom
|
||||||
|
|
||||||
@@ -2284,59 +2513,25 @@ function toggleChartSeries(key) {
|
|||||||
scheduleChartRedraw()
|
scheduleChartRedraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChartTouch(e) {
|
|
||||||
if (!chartCoords || !canvasWidth) return
|
|
||||||
if (!chartDates.value.length) return
|
|
||||||
let touchX = 0
|
|
||||||
let touchY = 0
|
|
||||||
if (e && e.touches && e.touches[0]) {
|
|
||||||
touchX = e.touches[0].x ?? e.touches[0].clientX ?? 0
|
|
||||||
touchY = e.touches[0].y ?? e.touches[0].clientY ?? 0
|
|
||||||
}
|
|
||||||
const { padding, cw, n } = chartCoords
|
|
||||||
if (touchX < padding.left || touchX > padding.left + cw) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ratio = n <= 1 ? 0 : (touchX - padding.left) / cw
|
|
||||||
let idx = Math.round(ratio * (n - 1))
|
|
||||||
if (idx < 0) idx = 0
|
|
||||||
if (idx > n - 1) idx = n - 1
|
|
||||||
const date = chartDates.value[idx]
|
|
||||||
const fp = chartSeriesVisible.value.fasting ? chartCoords.fasting[idx] : null
|
|
||||||
const pp = chartSeriesVisible.value.postprandial ? chartCoords.postprandial[idx] : null
|
|
||||||
const anchorX = fp?.x ?? pp?.x ?? padding.left + cw * (idx / Math.max(1, n - 1))
|
|
||||||
|
|
||||||
const left = Math.max(8, Math.min(canvasWidth - 130, anchorX - 60))
|
|
||||||
const top = Math.max(4, touchY - 70)
|
|
||||||
|
|
||||||
hoverInfo.value = {
|
|
||||||
date: date.slice(5),
|
|
||||||
fasting: fp?.value ?? null,
|
|
||||||
postprandial: pp?.value ?? null,
|
|
||||||
left,
|
|
||||||
top
|
|
||||||
}
|
|
||||||
if (hoverTimer) clearTimeout(hoverTimer)
|
|
||||||
hoverTimer = setTimeout(() => {
|
|
||||||
hoverInfo.value = null
|
|
||||||
}, 2500)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(rangeMode, () => {
|
watch(rangeMode, () => {
|
||||||
scheduleChartRedraw()
|
scheduleChartRedraw()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch([chartUseMockData, bloodRecords], () => {
|
||||||
|
scheduleChartRedraw()
|
||||||
|
})
|
||||||
|
|
||||||
// ============ 生命周期 ============
|
// ============ 生命周期 ============
|
||||||
function readUserContext() {
|
function readUserContext() {
|
||||||
try {
|
try {
|
||||||
const userData = uni.getStorageSync('userData')
|
const userData = uni.getStorageSync('userData')
|
||||||
if (userData && userData.diagnosis) {
|
if (!userData) return
|
||||||
if (!patientId.value) patientId.value = Number(userData.diagnosis.patient_id) || 0
|
// 勿用问诊会话里的 diagnosis 作为日常血糖诊单(无 view_records 会报无权)
|
||||||
if (!diagnosisId.value) diagnosisId.value = Number(userData.diagnosis.id) || 0
|
const name = String(userData.real_name || userData.nickname || '').trim()
|
||||||
if (!patientName.value) patientName.value = userData.diagnosis.patient_name || ''
|
if (!patientName.value && name) patientName.value = name
|
||||||
if (!age.value) age.value = Number(userData.diagnosis.age) || 0
|
const sex = Number(userData.sex)
|
||||||
if (!gender.value) gender.value = Number(userData.diagnosis.gender) || 0
|
if (!gender.value && (sex === 1 || sex === 2)) gender.value = sex === 1 ? 1 : 0
|
||||||
}
|
if (!age.value && userData.age) age.value = Number(userData.age) || 0
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2419,23 +2614,25 @@ async function bootstrap(options) {
|
|||||||
if (!target) target = list[0]
|
if (!target) target = list[0]
|
||||||
await applyCard(target)
|
await applyCard(target)
|
||||||
} else {
|
} else {
|
||||||
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
|
// 无可见就诊卡:勿用 URL/缓存里的隐藏诊单 ID
|
||||||
if (passedPatientId) patientId.value = passedPatientId
|
diagnosisId.value = 0
|
||||||
if (passedPatientName) patientName.value = passedPatientName
|
patientId.value = 0
|
||||||
if (passedAge) age.value = passedAge
|
patientName.value = ''
|
||||||
if (passedGender) gender.value = passedGender
|
age.value = 0
|
||||||
if (!diagnosisId.value || !patientId.value) {
|
gender.value = 0
|
||||||
readUserContext()
|
|
||||||
}
|
|
||||||
readUserMobile()
|
readUserMobile()
|
||||||
if (!diagnosisId.value && userMobile.value) {
|
if (userMobile.value) {
|
||||||
await ensureDailyContextByPhone()
|
await ensureDailyContextByPhone(undefined, { alert: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authChecking.value = false
|
authChecking.value = false
|
||||||
if (diagnosisId.value) {
|
if (diagnosisId.value) {
|
||||||
await fetchAll()
|
await fetchAll()
|
||||||
|
} else {
|
||||||
|
bloodRecords.value = []
|
||||||
|
await nextTick()
|
||||||
|
scheduleChartRedraw()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2445,6 +2642,7 @@ onLoad((options) => {
|
|||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
initHeaderSafeArea()
|
initHeaderSafeArea()
|
||||||
|
try { uni.onKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
|
||||||
if (authChecking.value) return
|
if (authChecking.value) return
|
||||||
resetCanvasCache()
|
resetCanvasCache()
|
||||||
nextTick(() => scheduleChartRedraw())
|
nextTick(() => scheduleChartRedraw())
|
||||||
@@ -2459,10 +2657,16 @@ onPullDownRefresh(async () => {
|
|||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
ttsStop()
|
ttsStop()
|
||||||
|
stopSpeechToText()
|
||||||
|
try { uni.offKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
|
||||||
|
keyboardHeight.value = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
ttsStop()
|
ttsStop()
|
||||||
|
stopSpeechToText()
|
||||||
|
try { uni.offKeyboardHeightChange(onKeyboardHeightChange) } catch (e) {}
|
||||||
|
keyboardHeight.value = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// ============ 邀请观看(家人分享页)============
|
// ============ 邀请观看(家人分享页)============
|
||||||
|
|||||||