Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
be15531ab3 | ||
|
|
1dd5133202 | ||
|
|
51b34a98b5 | ||
|
|
1755185f50 | ||
|
|
b3d3048100 | ||
|
|
a38af5bb13 | ||
|
|
0a781bda54 | ||
|
|
450be5852f | ||
|
|
3c35ab67cd | ||
|
|
cbf992d6df | ||
|
|
7aec1a9713 | ||
|
|
01bc355bed | ||
|
|
1b746b46c0 | ||
|
|
ceb313b1a7 | ||
|
|
7cfa4d269a | ||
|
|
576ab05681 | ||
|
|
132a17b946 | ||
|
|
75dfaa0dcd | ||
|
|
e83dd07553 | ||
|
|
c589bfa647 | ||
|
|
17ad153821 | ||
|
|
e4312ab5d1 | ||
|
|
7f4fad7b62 | ||
|
|
9b5cd5b2b9 | ||
|
|
94e787ae80 | ||
|
|
7b46204454 | ||
|
|
9f3db8f281 | ||
|
|
6cb8688587 | ||
|
|
0e5b5beb9c | ||
|
|
8d635bb7ed | ||
|
|
0f974be7a1 | ||
|
|
2b1ce61e72 | ||
|
|
6efc340bd8 | ||
|
|
c435e6ae4e | ||
|
|
dfb8838ecc | ||
|
|
7f08771dd0 | ||
|
|
afd88714ec | ||
|
|
bb21825c16 | ||
|
|
c3e4dfa479 | ||
|
|
33f8f669ad | ||
|
|
3ebe3c58c8 | ||
|
|
0225e9f971 | ||
|
|
4f066b560e | ||
|
|
13f58a5fdc | ||
|
|
36be8fedad | ||
|
|
b9d2541b32 | ||
|
|
261f8315de | ||
|
|
ffe4849c27 | ||
|
|
19af50c344 | ||
|
|
012830de4a | ||
|
|
8b0fcd7050 | ||
|
|
57e4892140 | ||
|
|
95fff1262b | ||
|
|
0dd9cdcba9 | ||
|
|
5b233fb0ab | ||
|
|
3325be0622 | ||
|
|
9af5c5be63 | ||
|
|
0e46bf8e0d | ||
|
|
b93313d935 | ||
|
|
c1dfb01f53 | ||
|
|
3f999b312d | ||
|
|
50abe5dece | ||
|
|
58c224a081 | ||
|
|
f718df833e | ||
|
|
f068acf390 | ||
|
|
769d912eef | ||
|
|
5aa0cda252 | ||
|
|
552da3aec2 | ||
|
|
ed5285b58a | ||
|
|
0d35d1b6c3 | ||
|
|
27c2ada4ec | ||
|
|
6be5b4c45d | ||
|
|
fbf3d47b2a | ||
|
|
82c4d7e0a2 | ||
|
|
867a6a97cf | ||
|
|
a3ee72b34d | ||
|
|
a08031b901 | ||
|
|
665481a6d8 | ||
|
|
30b344b776 | ||
|
|
e104249707 | ||
|
|
9b6d9ce007 | ||
|
|
69da8066a6 | ||
|
|
edc9993403 | ||
|
|
8f14f3190e | ||
|
|
71a3c63182 | ||
|
|
d10678763f | ||
|
|
2beace89f4 | ||
|
|
e5d0d7db93 | ||
|
|
35a42078ce | ||
|
|
e8e28d19fb | ||
|
|
163e40c73f | ||
|
|
d46cfff079 | ||
|
|
275a7a550d | ||
|
|
5430fe0417 | ||
|
|
f314e2e271 | ||
|
|
a4fb55d164 | ||
|
|
295c8f0623 | ||
|
|
4b749a1ee0 | ||
|
|
679e61057b | ||
|
|
7f7b8f0a64 | ||
|
|
7e94c5dfd7 | ||
|
|
003810114b | ||
|
|
37c5849103 | ||
|
|
55521b8e11 | ||
|
|
e4f181e4fe | ||
|
|
bba989c0af | ||
|
|
d5e0ba04b7 | ||
|
|
22ec53c070 | ||
|
|
183b6a1acf | ||
|
|
49c8c95966 | ||
|
|
c7c264497e | ||
|
|
436f69c272 | ||
|
|
dc72a8ccaf | ||
|
|
499c969233 | ||
|
|
3b44300216 | ||
|
|
8bb7b5d30a | ||
|
|
4c3becf33e | ||
|
|
a42fc6453a | ||
|
|
dad06304f1 | ||
|
|
03b80310f6 | ||
|
|
8d015fa962 | ||
|
|
f639ef2fbb | ||
|
|
ea8e6d7c1b | ||
|
|
ba1c067dce | ||
|
|
5e96fda88f | ||
|
|
076cd0fcf9 | ||
|
|
44e97861d2 | ||
|
|
93e6d02ce4 | ||
|
|
60cb2b80cd | ||
|
|
752eddb0ba | ||
|
|
61d14d1b45 | ||
|
|
a83c583a28 | ||
|
|
485ba7a380 | ||
|
|
1005859404 | ||
|
|
ea6c73c063 | ||
|
|
3e4039efb0 | ||
|
|
c9ad4ae2ed | ||
|
|
abf8026d45 | ||
|
|
26b442b5da | ||
|
|
1cb00e9fbe | ||
|
|
9208e7eaaa | ||
|
|
a1392f9491 | ||
|
|
2ea0e1d54b | ||
|
|
d96fa6f4a1 | ||
|
|
0fb51e12d1 | ||
|
|
4f9494db0b | ||
|
|
4e87b080a0 | ||
|
|
2f3038aba1 | ||
|
|
131fb7d9c7 | ||
|
|
762a724204 | ||
|
|
8090e0aab3 | ||
|
|
9df6330d41 | ||
|
|
fd63e23eaf | ||
|
|
c1143f4629 | ||
|
|
149fdffd49 | ||
|
|
439d5a3ec9 | ||
|
|
9e24d49210 | ||
|
|
90ac43fc82 | ||
|
|
8b6b709987 | ||
|
|
fb6ca9ce85 | ||
|
|
08290b329e | ||
|
|
1d66f84bd3 | ||
|
|
a2d9c1a03f | ||
|
|
d79db88349 | ||
|
|
9f5fb650af | ||
|
|
15b4339c90 | ||
|
|
5ce331394d | ||
|
|
800c6af7f1 | ||
|
|
876ef0f8a0 | ||
|
|
fdcfa30810 | ||
|
|
4caa98af3a | ||
|
|
bb87e4fb4c | ||
|
|
039105580c | ||
|
|
f47f431bdf |
@@ -26,10 +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
|
||||||
|
/.codegraph
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
{
|
{
|
||||||
"kiroAgent.configureMCP": "Disabled"
|
"kiroAgent.configureMCP": "Enabled"
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
|
unpackage/dist
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
@@ -21,3 +22,5 @@ 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'
|
||||||
@@ -15,11 +14,22 @@ const globalData = {
|
|||||||
let avatarUrl = ref(""); // 声明为响应式变量
|
let avatarUrl = ref(""); // 声明为响应式变量
|
||||||
uni.CallManager = new CallManager();
|
uni.CallManager = new CallManager();
|
||||||
onLaunch(() => {
|
onLaunch(() => {
|
||||||
|
// iOS 静音键模式下 InnerAudioContext 默认不发声,
|
||||||
|
// 必须在 onLaunch 全局调用此 API,单实例 obeyMuteSwitch 在 iOS 不可靠
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
try {
|
||||||
|
uni.setInnerAudioOption({
|
||||||
|
obeyMuteSwitch: false,
|
||||||
|
mixWithOther: true,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('setInnerAudioOption failed:', e)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
uni.login({
|
uni.login({
|
||||||
provider: 'weixin',
|
provider: 'weixin',
|
||||||
success: function (loginRes) {
|
success: function (loginRes) {
|
||||||
console.log('xx');
|
|
||||||
console.log(loginRes.code);
|
|
||||||
if( uni.getStorageSync('token')){
|
if( uni.getStorageSync('token')){
|
||||||
userinfo(loginRes.code);
|
userinfo(loginRes.code);
|
||||||
}else{
|
}else{
|
||||||
@@ -86,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;
|
||||||
@@ -104,4 +110,4 @@ const video =async (data)=>{
|
|||||||
uni.setStorageSync('CallManager', userId)
|
uni.setStorageSync('CallManager', userId)
|
||||||
await uni.CallManager.init(cc);
|
await uni.CallManager.init(cc);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
## 简介
|
## 简介
|
||||||
本 demo 演示了如何在 uni-app 项目中集成 [TUICallKit](https://www.npmjs.com/package/@trtc/calls-uikit-wx-uniapp) 音视频通话组件。
|
本 demo 演示了如何在 uni-app 项目中集成 [TUICallKit](https://www.npmjs.com/package/@trtc/calls-uikit-wx-uniapp) 音视频通话组件。
|
||||||
|
|
||||||
|
## 两种开发方式
|
||||||
|
|
||||||
|
本工程同时支持 **HBuilderX** 和 **命令行 (vite)** 两套编译链路,二选一即可。
|
||||||
|
|
||||||
|
### 方式 A:命令行 (vite)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # 安装依赖
|
||||||
|
npm run dev:mp-weixin # watch 模式,产物 dist/dev/mp-weixin
|
||||||
|
npm run build:mp-weixin # 生产构建,产物 dist/build/mp-weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
构建完成后用微信开发者工具打开对应 `dist/.../mp-weixin` 目录即可。
|
||||||
|
|
||||||
|
支持平台:`mp-weixin` / `h5` / `app`(脚本里有 dev/build 两组)。
|
||||||
|
|
||||||
|
### 方式 B:HBuilderX
|
||||||
|
|
||||||
|
HBuilderX 3.4+ 会自动识别 `vite.config.ts` 并切换到 vite 编译模式。
|
||||||
|
|
||||||
|
1. 用 HBuilderX 打开工程根目录
|
||||||
|
2. 菜单「运行」→「运行到小程序模拟器」→「微信开发者工具」
|
||||||
|
3. 产物会输出到 `unpackage/dist/dev/mp-weixin`
|
||||||
|
|
||||||
|
|
||||||
## 环境准备
|
## 环境准备
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 页面内嵌模块(如 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 class="menu-item" @click="goto('/training/pages/dumbbell')">
|
||||||
|
<view class="menu-icon menu-icon--dumbbell">
|
||||||
|
<view class="dumbbell-shape">
|
||||||
|
<view class="dumbbell-plate dumbbell-plate-left" />
|
||||||
|
<view class="dumbbell-bar" />
|
||||||
|
<view class="dumbbell-plate dumbbell-plate-right" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">哑铃</text>
|
||||||
|
<text class="menu-sub">力量塑形</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-arrow">›</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="menu-item" @click="goto('/training/pages/foot-pedal')">
|
||||||
|
<view class="menu-icon menu-icon--pedal">
|
||||||
|
<view class="foot-pedal-shape">
|
||||||
|
<view class="pedal-bar pedal-bar-left" />
|
||||||
|
<view class="pedal-bar pedal-bar-right" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">脚蹬器</text>
|
||||||
|
<text class="menu-sub">下肢有氧</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-arrow">›</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="menu-item" @click="goto('/training/pages/pilates-ring')">
|
||||||
|
<view class="menu-icon menu-icon--pilates">
|
||||||
|
<view class="pilates-ring-shape" />
|
||||||
|
</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">瑜伽环</text>
|
||||||
|
<text class="menu-sub">核心塑形</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-arrow">›</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="menu-item" @click="goto('/training/pages/grip-ring')">
|
||||||
|
<view class="menu-icon menu-icon--grip">
|
||||||
|
<view class="grip-ring-shape" />
|
||||||
|
</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">握力环</text>
|
||||||
|
<text class="menu-sub">握力训练</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-arrow">›</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="menu-item" @click="goto('/training/pages/metronome')">
|
||||||
|
<view class="menu-icon menu-icon--metro">
|
||||||
|
<view class="metro-bar metro-bar-1" />
|
||||||
|
<view class="metro-bar metro-bar-2" />
|
||||||
|
<view class="metro-bar metro-bar-3" />
|
||||||
|
</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">耗糖节拍器</text>
|
||||||
|
<text class="menu-sub">健走配速</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-arrow">›</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 主浮动按钮(单字"练" + 呼吸光环) -->
|
||||||
|
<view class="fab-container">
|
||||||
|
<view v-if="!expanded" class="fab-pulse" />
|
||||||
|
<view v-if="!expanded" class="fab-pulse fab-pulse-2" />
|
||||||
|
<view class="entry-fab" :class="{ expanded }" @click="toggle">
|
||||||
|
<view v-if="expanded" class="icon-close">
|
||||||
|
<view class="cross-bar bar-1" />
|
||||||
|
<view class="cross-bar bar-2" />
|
||||||
|
</view>
|
||||||
|
<!-- 心跳波形 icon: 健康主题,SVG 折线 -->
|
||||||
|
<view v-else class="fab-icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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(
|
||||||
|
defineProps<{
|
||||||
|
/** 悬浮按钮距底部 rpx(仅非 inline) */
|
||||||
|
bottom?: number
|
||||||
|
/** 内嵌到页面主内容区,非 fixed 悬浮 */
|
||||||
|
inline?: boolean
|
||||||
|
/** weekly 页:与统计页 Vital Mint 视觉一致 */
|
||||||
|
tone?: 'default' | 'weekly'
|
||||||
|
}>(),
|
||||||
|
{ bottom: 200, inline: false, tone: 'default' },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** weekly 将健走节拍器置前并标推荐 */
|
||||||
|
const inlineItems = computed(() => {
|
||||||
|
if (props.tone !== 'weekly') return trainingItems
|
||||||
|
const metro = trainingItems.find((i) => i.icon === 'metro')
|
||||||
|
const rest = trainingItems.filter((i) => i.icon !== 'metro')
|
||||||
|
return metro ? [metro, ...rest] : trainingItems
|
||||||
|
})
|
||||||
|
|
||||||
|
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
|
||||||
|
const visible = ref(true)
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const goto = (url: string) => {
|
||||||
|
expanded.value = false
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$brand: #10b981;
|
||||||
|
$brand-deep: #047857;
|
||||||
|
$brand-light: #34d399;
|
||||||
|
|
||||||
|
.dev-entry-wrap {
|
||||||
|
position: fixed;
|
||||||
|
right: 24rpx;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 主浮动按钮
|
||||||
|
* ============================================================ */
|
||||||
|
.fab-container {
|
||||||
|
position: relative;
|
||||||
|
width: 108rpx;
|
||||||
|
height: 108rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-fab {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(140deg, $brand-light 0%, $brand-deep 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 20rpx rgba(16, 185, 129, 0.42),
|
||||||
|
0 2rpx 6rpx rgba(15, 23, 42, 0.12),
|
||||||
|
inset 0 -8rpx 16rpx rgba(0, 0, 0, 0.12),
|
||||||
|
inset 0 8rpx 16rpx rgba(255, 255, 255, 0.18);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
background: linear-gradient(140deg, #94a3b8 0%, #475569 100%);
|
||||||
|
transform: rotate(135deg);
|
||||||
|
box-shadow:
|
||||||
|
0 6rpx 16rpx rgba(15, 23, 42, 0.2),
|
||||||
|
inset 0 -6rpx 14rpx rgba(0, 0, 0, 0.12),
|
||||||
|
inset 0 6rpx 14rpx rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-icon {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.6' 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;
|
||||||
|
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.22));
|
||||||
|
animation: fab-icon-beat 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes fab-icon-beat {
|
||||||
|
0%, 60%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关闭态 X 图标 (CSS 几何) */
|
||||||
|
.icon-close {
|
||||||
|
position: relative;
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
|
||||||
|
.cross-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 5rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
.bar-1 {
|
||||||
|
transform: translateY(-50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
.bar-2 {
|
||||||
|
transform: translateY(-50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 呼吸光环(2 圈错相位扩散) */
|
||||||
|
.fab-pulse {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(16, 185, 129, 0.25);
|
||||||
|
animation: fab-ring 2.4s ease-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.fab-pulse-2 {
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
@keyframes fab-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.55);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 展开菜单
|
||||||
|
* ============================================================ */
|
||||||
|
.menu-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20rpx) scale(0.92);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
padding: 14rpx 20rpx 14rpx 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 24rpx rgba(15, 23, 42, 0.1),
|
||||||
|
0 1rpx 2rpx rgba(15, 23, 42, 0.06);
|
||||||
|
min-width: 280rpx;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
backdrop-filter: blur(12rpx);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(16, 185, 129, 0.32);
|
||||||
|
|
||||||
|
&--train {
|
||||||
|
background: linear-gradient(140deg, $brand-light, $brand-deep);
|
||||||
|
}
|
||||||
|
&--dumbbell {
|
||||||
|
background: linear-gradient(140deg, #93c5fd, #3b82f6);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(59, 130, 246, 0.36);
|
||||||
|
}
|
||||||
|
&--pedal {
|
||||||
|
background: linear-gradient(140deg, #fdba74, #f97316);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(249, 115, 22, 0.36);
|
||||||
|
}
|
||||||
|
&--pilates {
|
||||||
|
background: linear-gradient(140deg, #c4b5fd, #8b5cf6);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(139, 92, 246, 0.36);
|
||||||
|
}
|
||||||
|
&--grip {
|
||||||
|
background: linear-gradient(140deg, #5eead4, #14b8a6);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(20, 184, 166, 0.36);
|
||||||
|
}
|
||||||
|
&--metro {
|
||||||
|
background: linear-gradient(140deg, #fbbf24, #d97706);
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 哑铃小 icon */
|
||||||
|
.dumbbell-shape {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dumbbell-plate {
|
||||||
|
width: 10rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dumbbell-bar {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 5rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
margin: 0 2rpx;
|
||||||
|
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 脚蹬器小 icon: 左右踏板 */
|
||||||
|
.foot-pedal-shape {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedal-bar {
|
||||||
|
width: 14rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4rpx 4rpx 2rpx 2rpx;
|
||||||
|
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedal-bar-left {
|
||||||
|
height: 18rpx;
|
||||||
|
transform: rotate(-12deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedal-bar-right {
|
||||||
|
height: 22rpx;
|
||||||
|
transform: rotate(12deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 瑜伽环小 icon: 双弧阻力环 */
|
||||||
|
.pilates-ring-shape {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 5rpx solid #fff;
|
||||||
|
position: relative;
|
||||||
|
box-shadow:
|
||||||
|
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
|
||||||
|
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 18rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3rpx solid rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 握力环小 icon: 白色描边的环(甜甜圈) */
|
||||||
|
.grip-ring-shape {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 7rpx solid #fff;
|
||||||
|
box-shadow:
|
||||||
|
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
|
||||||
|
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
|
||||||
|
.metro-bar {
|
||||||
|
width: 4rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.metro-bar-1 {
|
||||||
|
height: 16rpx;
|
||||||
|
}
|
||||||
|
.metro-bar-2 {
|
||||||
|
height: 28rpx;
|
||||||
|
}
|
||||||
|
.metro-bar-3 {
|
||||||
|
height: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
.menu-sub {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-arrow {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
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>
|
||||||
@@ -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值
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import App from './App'
|
import App from './App'
|
||||||
var baseUrl ='https://admin.zhenyangtang.com.cn/';
|
var baseUrl ='https://admin.zhenyangtang.com.cn/';
|
||||||
|
|
||||||
|
function joinApiUrl(base, path) {
|
||||||
|
const b = String(base || '').replace(/\/+$/, '')
|
||||||
|
const p = String(path || '').replace(/^\/+/, '')
|
||||||
|
return p ? `${b}/${p}` : b
|
||||||
|
}
|
||||||
// #ifndef VUE3
|
// #ifndef VUE3
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import './uni.promisify.adaptor'
|
import './uni.promisify.adaptor'
|
||||||
@@ -20,13 +26,13 @@ Vue.prototype.apiUrl =function apiurl(promise, Loading = true){
|
|||||||
if (promise.data) {
|
if (promise.data) {
|
||||||
data = promise.data
|
data = promise.data
|
||||||
}
|
}
|
||||||
let url=Vue.prototype.$url+promise.url
|
let url = joinApiUrl(Vue.prototype.$url, promise.url)
|
||||||
|
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise(function(resolve, reject) {
|
||||||
uni.request({
|
uni.request({
|
||||||
url: url,
|
url: url,
|
||||||
data: data,
|
data: data,
|
||||||
timeout: 15000,
|
timeout: promise.timeout != null ? promise.timeout : 15000,
|
||||||
method: promise.method ? promise.method : 'POST',
|
method: promise.method ? promise.method : 'POST',
|
||||||
header: header,
|
header: header,
|
||||||
sslVerify: false,
|
sslVerify: false,
|
||||||
@@ -73,7 +79,7 @@ function apiUrl(promise, Loading = true) {
|
|||||||
|
|
||||||
// 处理请求数据和URL
|
// 处理请求数据和URL
|
||||||
const data = promise.data || {}
|
const data = promise.data || {}
|
||||||
const url = baseUrl + (promise.url || '')
|
const url = joinApiUrl(baseUrl, promise.url || '')
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 可选:根据Loading参数显示加载中
|
// 可选:根据Loading参数显示加载中
|
||||||
@@ -84,7 +90,7 @@ function apiUrl(promise, Loading = true) {
|
|||||||
uni.request({
|
uni.request({
|
||||||
url: url,
|
url: url,
|
||||||
data: data,
|
data: data,
|
||||||
timeout: 15000,
|
timeout: promise.timeout != null ? promise.timeout : 15000,
|
||||||
method: promise.method || 'POST', // 简化三元表达式
|
method: promise.method || 'POST', // 简化三元表达式
|
||||||
header: header,
|
header: header,
|
||||||
sslVerify: false,
|
sslVerify: false,
|
||||||
|
|||||||
@@ -51,13 +51,22 @@
|
|||||||
/* 小程序特有相关 */
|
/* 小程序特有相关 */
|
||||||
"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" ],
|
||||||
|
"plugins" : {
|
||||||
|
"WechatSI" : {
|
||||||
|
"version" : "0.3.9",
|
||||||
|
"provider" : "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mp-alipay" : {
|
"mp-alipay" : {
|
||||||
"usingComponents" : true
|
"usingComponents" : true
|
||||||
|
|||||||
@@ -1,17 +1,55 @@
|
|||||||
{
|
{
|
||||||
"name": "tuicallkit-vue3",
|
"name": "tuicallkit-vue3",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "TUICallKit Vue3 uni-app 工程(兼容 HBuilderX & vite CLI)",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"dev:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni -p mp-weixin",
|
||||||
|
"dev:h5": "UNI_INPUT_DIR=\"$PWD\" uni",
|
||||||
|
"dev:app": "UNI_INPUT_DIR=\"$PWD\" uni -p app",
|
||||||
|
"build:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni build -p mp-weixin",
|
||||||
|
"build:h5": "UNI_INPUT_DIR=\"$PWD\" uni build",
|
||||||
|
"build:app": "UNI_INPUT_DIR=\"$PWD\" uni build -p app"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-app-harmony": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-alipay": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-baidu": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-jd": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-kuaishou": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-lark": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-qq": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-toutiao": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-mp-xhs": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||||
"@trtc/calls-uikit-wx-uniapp": "^4.1.0",
|
"@trtc/calls-uikit-wx-uniapp": "^4.1.0",
|
||||||
"@uni-ku/bundle-optimizer": "^1.3.16",
|
"@uni-ku/bundle-optimizer": "^1.3.16",
|
||||||
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9"
|
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^9.1.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dcloudio/types": "^3.4.8",
|
||||||
|
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@vue/runtime-core": "^3.4.21",
|
||||||
|
"less": "^4.6.4",
|
||||||
|
"sass": "^1.77.8",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.2.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
@@ -105,25 +114,126 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "training",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/grip-ring",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "握力环训练",
|
||||||
|
"navigationBarBackgroundColor": "#f8fafc",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#f8fafc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/pilates-ring",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "瑜伽环训练",
|
||||||
|
"navigationBarBackgroundColor": "#f8fafc",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#f8fafc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/foot-pedal",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "脚蹬器训练",
|
||||||
|
"navigationBarBackgroundColor": "#f8fafc",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#f8fafc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/dumbbell",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "哑铃训练",
|
||||||
|
"navigationBarBackgroundColor": "#f8fafc",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#f8fafc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/metronome",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "耗糖节拍器",
|
||||||
|
"navigationBarBackgroundColor": "#f8fafc",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#f8fafc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "tongji",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "血糖记录",
|
||||||
|
"navigationBarBackgroundColor": "#204e2b",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"backgroundColor": "#faf9f5",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"mp-weixin": {
|
||||||
|
"usingPlugins": {
|
||||||
|
"WechatSI": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"provider": "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/weekly",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "血糖管理",
|
||||||
|
"backgroundColor": "#f4fbf4",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"mp-weixin": {
|
||||||
|
"usingPlugins": {
|
||||||
|
"WechatSI": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"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": "甄养堂互联网医院",
|
||||||
|
|||||||
@@ -53,6 +53,18 @@
|
|||||||
<text class="time-value">{{ card.create_time || '-' }}</text>
|
<text class="time-value">{{ card.create_time || '-' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 快捷入口 -->
|
||||||
|
<view class="card-actions">
|
||||||
|
<view class="card-action-btn primary" @click.stop="viewDailyRecord(card)">
|
||||||
|
<text class="card-action-icon">📈</text>
|
||||||
|
<text class="card-action-text">日常记录</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-action-btn" @click.stop="viewCardDetail(card)">
|
||||||
|
<text class="card-action-icon">📝</text>
|
||||||
|
<text class="card-action-text">查看 / 编辑</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
@@ -138,6 +150,16 @@ const viewCardDetail = (card) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查看日常记录(血糖血压 / 饮食 / 运动 / 跟踪备注 + 波浪图)
|
||||||
|
const viewDailyRecord = (card) => {
|
||||||
|
const url = `/tongji/pages/index?diagnosis_id=${card.id}` +
|
||||||
|
`&patient_id=${card.patient_id || ''}` +
|
||||||
|
`&patient_name=${encodeURIComponent(card.patient_name || '')}` +
|
||||||
|
`&age=${card.age || ''}` +
|
||||||
|
`&gender=${card.gender || ''}`
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
}
|
||||||
|
|
||||||
// 新建就诊卡(patient_id 创建后自动生成,仅需登录)
|
// 新建就诊卡(patient_id 创建后自动生成,仅需登录)
|
||||||
const createCard = () => {
|
const createCard = () => {
|
||||||
const token = uni.getStorageSync('token')
|
const token = uni.getStorageSync('token')
|
||||||
@@ -310,6 +332,54 @@ const formatDate = (timestamp) => {
|
|||||||
border-top: 4rpx solid #e8eaed;
|
border-top: 4rpx solid #e8eaed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
padding-top: 28rpx;
|
||||||
|
border-top: 4rpx solid #e8eaed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 24rpx 12rpx;
|
||||||
|
background: #f6faff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
border: 2rpx solid #d6e6fb;
|
||||||
|
min-height: 80rpx;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: linear-gradient(135deg, #1890ff, #0ea5a4);
|
||||||
|
border-color: transparent;
|
||||||
|
|
||||||
|
.card-action-icon,
|
||||||
|
.card-action-text {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.time-info {
|
.time-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -778,6 +778,7 @@ const showDatePicker = ref(false)
|
|||||||
const diagnosisId = ref(null)
|
const diagnosisId = ref(null)
|
||||||
const patientId = ref(null)
|
const patientId = ref(null)
|
||||||
const isAddMode = ref(false) // 新建模式
|
const isAddMode = ref(false) // 新建模式
|
||||||
|
const returnUrl = ref('') // 保存成功后跳回(如日常记录页)
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
@@ -1023,6 +1024,13 @@ const loadCardDetail = async () => {
|
|||||||
const options = currentPage.options || {}
|
const options = currentPage.options || {}
|
||||||
diagnosisId.value = options.id
|
diagnosisId.value = options.id
|
||||||
isAddMode.value = options.add === '1' || options.add === 1
|
isAddMode.value = options.add === '1' || options.add === 1
|
||||||
|
if (options.returnUrl) {
|
||||||
|
try {
|
||||||
|
returnUrl.value = decodeURIComponent(String(options.returnUrl))
|
||||||
|
} catch (e) {
|
||||||
|
returnUrl.value = String(options.returnUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userData = uni.getStorageSync('userData')
|
const userData = uni.getStorageSync('userData')
|
||||||
patientId.value = userData?.diagnosis?.patient_id
|
patientId.value = userData?.diagnosis?.patient_id
|
||||||
@@ -1302,7 +1310,12 @@ const submitForm = async () => {
|
|||||||
}
|
}
|
||||||
uni.showToast({ title: isAddMode.value ? '创建成功' : '保存成功', icon: 'success' })
|
uni.showToast({ title: isAddMode.value ? '创建成功' : '保存成功', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.navigateBack()
|
const back = returnUrl.value
|
||||||
|
if (back && back.startsWith('/')) {
|
||||||
|
uni.redirectTo({ url: back })
|
||||||
|
} else {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} else {
|
} else {
|
||||||
uni.showToast({ title: res.msg || '保存失败', icon: 'none' })
|
uni.showToast({ title: res.msg || '保存失败', icon: 'none' })
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -88,13 +88,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- DEV 开发悬浮入口(生产环境自动隐藏) -->
|
||||||
|
<dev-training-entry :bottom="360" />
|
||||||
|
<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 TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'profileB',
|
name: 'profileB',
|
||||||
|
components: { DevTrainingEntry, TabBarDock },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
userInfo: {
|
userInfo: {
|
||||||
@@ -165,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 {
|
||||||
@@ -415,4 +423,4 @@ export default {
|
|||||||
.bg-cyan { background: #13c2c2; }
|
.bg-cyan { background: #13c2c2; }
|
||||||
.bg-pink { background: #eb2f96; }
|
.bg-pink { background: #eb2f96; }
|
||||||
.bg-gray { background: #666666; }
|
.bg-gray { background: #666666; }
|
||||||
</style>
|
</style>
|
||||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="show" class="celebrate-root" @touchmove.stop.prevent>
|
||||||
|
<view
|
||||||
|
v-for="p in particles"
|
||||||
|
:key="p.id"
|
||||||
|
class="celebrate-particle"
|
||||||
|
:style="p.style"
|
||||||
|
/>
|
||||||
|
<view v-if="title" class="celebrate-card" :class="{ pop: cardPop }">
|
||||||
|
<view class="celebrate-card-glow" />
|
||||||
|
<TongjiIcon name="sparkles" size="lg" color="#204E2B" />
|
||||||
|
<text class="celebrate-card-title">{{ title }}</text>
|
||||||
|
<text v-if="subtitle" class="celebrate-card-sub">{{ subtitle }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import TongjiIcon from './TongjiIcon.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const particles = ref([])
|
||||||
|
const cardPop = ref(false)
|
||||||
|
|
||||||
|
const COLORS = ['#204E2B', '#386641', '#AFE2B3', '#FBBF24', '#727970', '#DC2626']
|
||||||
|
|
||||||
|
function buildParticles() {
|
||||||
|
const list = []
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const left = 8 + Math.random() * 84
|
||||||
|
const delay = Math.random() * 0.35
|
||||||
|
const hue = COLORS[i % COLORS.length]
|
||||||
|
const size = 10 + Math.floor(Math.random() * 14)
|
||||||
|
list.push({
|
||||||
|
id: `${Date.now()}_${i}`,
|
||||||
|
style: {
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `${size}rpx`,
|
||||||
|
height: `${size}rpx`,
|
||||||
|
background: hue,
|
||||||
|
animationDelay: `${delay}s`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
particles.value = buildParticles()
|
||||||
|
cardPop.value = false
|
||||||
|
setTimeout(() => {
|
||||||
|
cardPop.value = true
|
||||||
|
}, 30)
|
||||||
|
} else {
|
||||||
|
particles.value = []
|
||||||
|
cardPop.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.celebrate-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.celebrate-particle {
|
||||||
|
position: absolute;
|
||||||
|
top: -20rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
opacity: 0.9;
|
||||||
|
animation: celebrate-fall 1.6s ease-in forwards;
|
||||||
|
}
|
||||||
|
@keyframes celebrate-fall {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) rotate(0deg) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(110vh) rotate(540deg) scale(0.4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.celebrate-card {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 38%;
|
||||||
|
transform: translate(-50%, -50%) scale(0.82);
|
||||||
|
width: 78%;
|
||||||
|
max-width: 560rpx;
|
||||||
|
padding: 36rpx 32rpx 32rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
box-shadow: 0 20rpx 60rpx rgba(8, 145, 178, 0.22);
|
||||||
|
border: 2rpx solid rgba(8, 145, 178, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
&.pop {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.celebrate-card-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -20rpx;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.celebrate-card-title {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1b1c1a;
|
||||||
|
}
|
||||||
|
.celebrate-card-sub {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="sugar-tree-graphic"
|
||||||
|
:class="[`lv-${clampedLevel}`, `tier-${visualTier}`, `size-${size}`, { watering: watering, 'is-max': clampedLevel >= MAX_LEVEL }]"
|
||||||
|
>
|
||||||
|
<view class="stg-glow" />
|
||||||
|
<view v-if="clampedLevel >= 7" class="stg-aura" />
|
||||||
|
<image v-if="!treeUseFallback" class="stg-image" :src="treeImageSrc" mode="aspectFit" @error="treeUseFallback = true" />
|
||||||
|
<text v-else class="stg-emoji">{{ treeEmoji }}</text>
|
||||||
|
<view v-if="clampedLevel >= 7" class="stg-sparkle stg-sparkle-a" />
|
||||||
|
<view v-if="clampedLevel >= 8" class="stg-sparkle stg-sparkle-b" />
|
||||||
|
<view v-if="clampedLevel >= MAX_LEVEL" class="stg-sparkle stg-sparkle-c" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||||
|
import { TREE_MAX_LEVEL, TREE_LEVELS } from '../utils/treeLevels.js'
|
||||||
|
|
||||||
|
const MAX_LEVEL = TREE_MAX_LEVEL
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
const treeUseFallback = ref(true)
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
const treeUseFallback = ref(false)
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
level: { type: Number, default: 0 },
|
||||||
|
size: { type: String, default: 'md' },
|
||||||
|
watering: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const clampedLevel = computed(() => Math.min(MAX_LEVEL, Math.max(0, Number(props.level) || 0)))
|
||||||
|
const visualTier = computed(() => {
|
||||||
|
const lv = clampedLevel.value
|
||||||
|
if (lv <= 0) return 0
|
||||||
|
if (lv <= 2) return 1
|
||||||
|
if (lv <= 4) return 2
|
||||||
|
if (lv <= 6) return 3
|
||||||
|
if (lv <= 8) return 4
|
||||||
|
return 5
|
||||||
|
})
|
||||||
|
const treeEmoji = computed(() => (TREE_LEVELS[clampedLevel.value] || TREE_LEVELS[0]).emoji)
|
||||||
|
|
||||||
|
function buildTreeSvg(level) {
|
||||||
|
const pot = `
|
||||||
|
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#204e2b" opacity="0.12"/>
|
||||||
|
<path d="M11 46h26c1.2 0 2 1 2 2.2v3.8c0 1-.8 1.8-1.8 1.8H10.8c-1 0-1.8-.8-1.8-1.8v-3.8c0-1.2.8-2.2 2-2.2z" fill="#E7E5E4"/>
|
||||||
|
<path d="M12.5 46h23c.8 0 1.5.7 1.5 1.5v2.2c0 .6-.5 1.1-1.1 1.1H12.1c-.6 0-1.1-.5-1.1-1.1v-2.2c0-.8.7-1.5 1.5-1.5z" fill="#D6D3D1"/>
|
||||||
|
<ellipse cx="24" cy="46.5" rx="9" ry="1.6" fill="#A8A29E" opacity="0.35"/>
|
||||||
|
`
|
||||||
|
const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#386641" opacity="0.18"/>`
|
||||||
|
|
||||||
|
if (level <= 0) {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">
|
||||||
|
${pot}${soil}
|
||||||
|
<circle cx="24" cy="41.5" r="2.2" fill="#a8a29e" opacity="0.7"/>
|
||||||
|
<path d="M24 41.5v3.5" stroke="#78716c" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const trunkH = level >= 6 ? 16 : level >= 3 ? 14 : 10
|
||||||
|
const trunkY = 46 - trunkH
|
||||||
|
const trunk = `<rect x="22.2" y="${trunkY}" width="3.6" height="${trunkH}" rx="1.8" fill="#78716C"/>
|
||||||
|
<rect x="22.6" y="${trunkY + 1}" width="2.8" height="${trunkH - 1}" rx="1.4" fill="#A8A29E" opacity="0.35"/>`
|
||||||
|
|
||||||
|
const leaf = (cx, cy, r, fill, opacity = 1) =>
|
||||||
|
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" opacity="${opacity}"/>
|
||||||
|
<circle cx="${cx - r * 0.25}" cy="${cy - r * 0.2}" r="${r * 0.35}" fill="#eef6ef" opacity="0.55"/>`
|
||||||
|
|
||||||
|
const bloom =
|
||||||
|
level >= 7
|
||||||
|
? leaf(17, 18, 3, '#fda4af', 0.95) +
|
||||||
|
leaf(31, 17, 2.8, '#f9a8d4', 0.9) +
|
||||||
|
leaf(24, 13, 3.2, '#fb7185', 0.95) +
|
||||||
|
`<circle cx="24" cy="12" r="1.3" fill="#fef3c7"/>`
|
||||||
|
: ''
|
||||||
|
const crown =
|
||||||
|
level >= 9
|
||||||
|
? leaf(12, 22, 6, '#34d399', 0.9) +
|
||||||
|
leaf(36, 22, 6, '#34d399', 0.9) +
|
||||||
|
leaf(24, 10, 7, '#386641', 0.95) +
|
||||||
|
bloom
|
||||||
|
: bloom
|
||||||
|
|
||||||
|
let canopy = ''
|
||||||
|
if (level === 1) {
|
||||||
|
canopy = leaf(24, 38, 4, '#afe2b3') + `<path d="M24 38v-5" stroke="#386641" stroke-width="1.2" stroke-linecap="round"/>`
|
||||||
|
} else if (level === 2) {
|
||||||
|
canopy = leaf(24, 32, 5.5, '#34d399') + leaf(20, 34, 3.5, '#6ee7b7', 0.9)
|
||||||
|
} else if (level <= 4) {
|
||||||
|
canopy =
|
||||||
|
leaf(24, 28, 7, '#34d399') +
|
||||||
|
leaf(17, 30, 5, '#6ee7b7', 0.9) +
|
||||||
|
leaf(31, 30, 5, '#6ee7b7', 0.9)
|
||||||
|
} else if (level <= 6) {
|
||||||
|
canopy =
|
||||||
|
leaf(24, 24, 9, '#22c55e') +
|
||||||
|
leaf(14, 26, 7, '#4ade80', 0.92) +
|
||||||
|
leaf(34, 26, 7, '#4ade80', 0.92) +
|
||||||
|
leaf(24, 16, 6, '#386641', 0.88)
|
||||||
|
} else {
|
||||||
|
canopy =
|
||||||
|
leaf(24, 20, 10, '#204e2b') +
|
||||||
|
leaf(13, 24, 8, '#34d399', 0.95) +
|
||||||
|
leaf(35, 24, 8, '#34d399', 0.95) +
|
||||||
|
crown
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">${pot}${soil}${trunk}${canopy}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value)))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sugar-tree-graphic {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.size-sm { width: 64rpx; height: 72rpx; }
|
||||||
|
.size-md { width: 80rpx; height: 88rpx; }
|
||||||
|
.size-lg { width: 112rpx; height: 124rpx; }
|
||||||
|
|
||||||
|
.stg-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 6%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(32, 78, 43, 0.22) 0%, transparent 68%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.lv-0 .stg-glow { background: radial-gradient(circle, rgba(148, 163, 184, 0.25) 0%, transparent 70%); }
|
||||||
|
.tier-4 .stg-glow,
|
||||||
|
.tier-5 .stg-glow,
|
||||||
|
.is-max .stg-glow {
|
||||||
|
background: radial-gradient(circle, rgba(251, 191, 36, 0.3) 0%, rgba(32, 78, 43, 0.15) 55%, transparent 72%);
|
||||||
|
}
|
||||||
|
.stg-aura {
|
||||||
|
position: absolute;
|
||||||
|
inset: -8%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2rpx solid rgba(253, 224, 71, 0.35);
|
||||||
|
animation: stg-aura-pulse 2.4s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes stg-aura-pulse {
|
||||||
|
0%, 100% { transform: scale(0.92); opacity: 0.5; }
|
||||||
|
50% { transform: scale(1.05); opacity: 1; }
|
||||||
|
}
|
||||||
|
.stg-image {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.35s ease;
|
||||||
|
}
|
||||||
|
.sugar-tree-graphic.watering .stg-image,
|
||||||
|
.sugar-tree-graphic.watering .stg-emoji {
|
||||||
|
animation: stg-water-bounce 0.65s ease;
|
||||||
|
}
|
||||||
|
.stg-emoji {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 72rpx;
|
||||||
|
}
|
||||||
|
.size-sm .stg-emoji { font-size: 48rpx; }
|
||||||
|
.size-md .stg-emoji { font-size: 60rpx; }
|
||||||
|
.size-lg .stg-emoji { font-size: 88rpx; }
|
||||||
|
@keyframes stg-water-bounce {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
35% { transform: scale(1.1) translateY(-6rpx); }
|
||||||
|
60% { transform: scale(0.96) translateY(2rpx); }
|
||||||
|
}
|
||||||
|
.stg-sparkle {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fde68a;
|
||||||
|
box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: stg-sparkle-twinkle 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.stg-sparkle-a { top: 4%; right: 16%; }
|
||||||
|
.stg-sparkle-b { top: 12%; left: 10%; width: 6rpx; height: 6rpx; animation-delay: 0.4s; }
|
||||||
|
.stg-sparkle-c { top: 22%; right: 28%; width: 10rpx; height: 10rpx; animation-delay: 0.8s; }
|
||||||
|
@keyframes stg-sparkle-twinkle {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scale(0.8); }
|
||||||
|
50% { opacity: 1; transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
|
||||||
|
<image
|
||||||
|
v-if="!useFallback"
|
||||||
|
class="tj-icon"
|
||||||
|
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
|
||||||
|
:src="iconSrc"
|
||||||
|
mode="aspectFit"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
<!-- <text
|
||||||
|
v-else
|
||||||
|
class="tj-icon-fallback"
|
||||||
|
:class="[`tj-icon-fallback--${size}`]"
|
||||||
|
:style="{ color }"
|
||||||
|
>{{ fallbackGlyph }}</text> -->
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
size: { type: String, default: 'md' },
|
||||||
|
color: { type: String, default: '#204E2B' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const useFallback = ref(false)
|
||||||
|
|
||||||
|
/** Lucide 风格描边路径 */
|
||||||
|
const ICON_PATHS = {
|
||||||
|
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
|
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
|
||||||
|
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||||
|
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||||
|
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
|
||||||
|
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
|
||||||
|
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
|
||||||
|
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||||
|
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||||
|
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||||
|
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||||
|
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
|
||||||
|
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||||
|
minus: '<path d="M5 12h14"/>',
|
||||||
|
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
|
||||||
|
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
|
||||||
|
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
|
||||||
|
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
|
||||||
|
plus: '<path d="M5 12h14M12 5v14"/>',
|
||||||
|
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
|
||||||
|
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
|
||||||
|
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
|
||||||
|
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
|
||||||
|
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
|
||||||
|
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||||
|
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
|
||||||
|
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||||
|
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
||||||
|
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
|
||||||
|
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
|
||||||
|
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
|
||||||
|
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"/>',
|
||||||
|
'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-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 回退 */
|
||||||
|
const ICON_FALLBACK = {
|
||||||
|
view: '👁',
|
||||||
|
ticket: '🎫',
|
||||||
|
calendar: '📅',
|
||||||
|
flame: '🔥',
|
||||||
|
'check-circle': '✓',
|
||||||
|
glucose: '💧',
|
||||||
|
heart: '❤',
|
||||||
|
activity: '🏃',
|
||||||
|
users: '👥',
|
||||||
|
user: '👤',
|
||||||
|
person: '👤',
|
||||||
|
send: '➤',
|
||||||
|
'alert-triangle': '⚠',
|
||||||
|
minus: '—',
|
||||||
|
droplet: '💧',
|
||||||
|
sparkles: '✨',
|
||||||
|
trophy: '🏆',
|
||||||
|
share: '↗',
|
||||||
|
plus: '+',
|
||||||
|
refresh: '↻',
|
||||||
|
volume: '🔊',
|
||||||
|
pause: '⏸',
|
||||||
|
play: '▶',
|
||||||
|
info: '!',
|
||||||
|
sun: '☀',
|
||||||
|
moon: '🌙',
|
||||||
|
sunset: '☀',
|
||||||
|
mic: '🎤',
|
||||||
|
camera: '📷',
|
||||||
|
bulb: '💡',
|
||||||
|
leaf: '🥬',
|
||||||
|
utensils: '🍴',
|
||||||
|
egg: '🍳',
|
||||||
|
home: '🏠',
|
||||||
|
'plus-circle': '⊕',
|
||||||
|
'chevron-right': '›',
|
||||||
|
'chevron-left': '‹',
|
||||||
|
check: '✓',
|
||||||
|
settings: '⚙',
|
||||||
|
syringe: '💉',
|
||||||
|
zap: '⚡'
|
||||||
|
}
|
||||||
|
|
||||||
|
const strokeColor = computed(() => {
|
||||||
|
const c = String(props.color || '#204E2B').trim()
|
||||||
|
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconSrc = computed(() => {
|
||||||
|
const path = ICON_PATHS[props.name] || ICON_PATHS.view
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||||
|
return svgToDataUrl(svg)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
|
||||||
|
|
||||||
|
function onImageError() {
|
||||||
|
useFallback.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tj-icon-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tj-icon-wrap--sm {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
}
|
||||||
|
.tj-icon-wrap--md {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
.tj-icon-wrap--lg {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
}
|
||||||
|
.tj-icon-wrap--xl {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
}
|
||||||
|
.tj-icon {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
|
||||||
|
.tj-icon--send {
|
||||||
|
transform: translate(-10%, 10%);
|
||||||
|
}
|
||||||
|
.tj-icon-fallback {
|
||||||
|
display: block;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tj-icon-fallback--sm {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
.tj-icon-fallback--md {
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
.tj-icon-fallback--lg {
|
||||||
|
font-size: 36rpx;
|
||||||
|
}
|
||||||
|
.tj-icon-fallback--xl {
|
||||||
|
font-size: 42rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
|
||||||
|
|
||||||
|
const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
|
||||||
|
|
||||||
|
const emptyDietAiRecommend = () => ({
|
||||||
|
breakfast: '',
|
||||||
|
drinks: '',
|
||||||
|
lunch: '',
|
||||||
|
dinner: '',
|
||||||
|
tips: '',
|
||||||
|
avoid: [],
|
||||||
|
analysis: [],
|
||||||
|
exercise: null,
|
||||||
|
disclaimer: '',
|
||||||
|
rules_summary: '',
|
||||||
|
source: '',
|
||||||
|
date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** AI 饮食助手(今日推荐 + 能不能吃) */
|
||||||
|
export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) {
|
||||||
|
const dietAiRecommend = ref(emptyDietAiRecommend())
|
||||||
|
const dietAiLoading = ref(false)
|
||||||
|
const dietAiStreaming = ref(false)
|
||||||
|
const dietAiReplacing = ref(false)
|
||||||
|
const dietAiStreamField = ref('')
|
||||||
|
let dietStreamBuffer = ''
|
||||||
|
const dietAiAskText = ref('')
|
||||||
|
const dietAiAsking = ref(false)
|
||||||
|
const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' })
|
||||||
|
|
||||||
|
const hasDietAiMealContent = computed(() => {
|
||||||
|
const r = dietAiRecommend.value
|
||||||
|
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
|
||||||
|
})
|
||||||
|
|
||||||
|
function detectDietStreamField(text) {
|
||||||
|
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||||
|
if (!tail) return 'breakfast'
|
||||||
|
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
||||||
|
return map[tail[1]] || 'breakfast'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePartialDietFromStream() {
|
||||||
|
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
||||||
|
const next = { ...dietAiRecommend.value }
|
||||||
|
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
|
||||||
|
if (partial[key] != null) next[key] = partial[key]
|
||||||
|
}
|
||||||
|
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
||||||
|
dietAiRecommend.value = next
|
||||||
|
dietAiStreamField.value = detectDietStreamField(dietStreamBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDietRecommendPayload(payload) {
|
||||||
|
if (!payload) return
|
||||||
|
const prevAnalysis = dietAiRecommend.value.analysis
|
||||||
|
const prevExercise = dietAiRecommend.value.exercise
|
||||||
|
dietAiRecommend.value = {
|
||||||
|
...emptyDietAiRecommend(),
|
||||||
|
...payload,
|
||||||
|
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
|
||||||
|
analysis: Array.isArray(payload.analysis) && payload.analysis.length
|
||||||
|
? payload.analysis
|
||||||
|
: (Array.isArray(prevAnalysis) ? prevAnalysis : []),
|
||||||
|
exercise: (payload.exercise && typeof payload.exercise === 'object')
|
||||||
|
? payload.exercise
|
||||||
|
: (prevExercise || null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDietDonePayload(payload) {
|
||||||
|
if (!payload) return payload
|
||||||
|
if (payload.source !== 'rule' || !dietStreamBuffer) return payload
|
||||||
|
|
||||||
|
const streamed = parsePartialDietPlainText(dietStreamBuffer)
|
||||||
|
if (!streamed.breakfast) return payload
|
||||||
|
|
||||||
|
return {
|
||||||
|
...emptyDietAiRecommend(),
|
||||||
|
...payload,
|
||||||
|
breakfast: streamed.breakfast,
|
||||||
|
drinks: streamed.drinks || payload.drinks || '',
|
||||||
|
lunch: streamed.lunch || payload.lunch || '',
|
||||||
|
dinner: streamed.dinner || payload.dinner || '',
|
||||||
|
tips: streamed.tips || payload.tips || '',
|
||||||
|
avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []),
|
||||||
|
source: 'ai',
|
||||||
|
disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDietAiStreamEvent(event, payload) {
|
||||||
|
if (event === 'analysis') {
|
||||||
|
if (Array.isArray(payload?.analysis)) {
|
||||||
|
dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis }
|
||||||
|
}
|
||||||
|
} else if (event === 'exercise') {
|
||||||
|
if (payload?.exercise && typeof payload.exercise === 'object') {
|
||||||
|
dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise }
|
||||||
|
}
|
||||||
|
} else if (event === 'delta') {
|
||||||
|
dietAiStreaming.value = true
|
||||||
|
if (payload.buffer != null) {
|
||||||
|
dietStreamBuffer = payload.buffer
|
||||||
|
} else {
|
||||||
|
dietStreamBuffer += payload.text || ''
|
||||||
|
}
|
||||||
|
mergePartialDietFromStream()
|
||||||
|
} else if (event === 'done') {
|
||||||
|
applyDietRecommendPayload(resolveDietDonePayload(payload))
|
||||||
|
dietStreamBuffer = ''
|
||||||
|
dietAiStreamField.value = ''
|
||||||
|
} else if (event === 'error') {
|
||||||
|
showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDietAiRecommendFallback(refresh) {
|
||||||
|
const res = await proxy.apiUrl({
|
||||||
|
url: '/api/tcm/dailyDietAiRecommend',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 35000,
|
||||||
|
data: {
|
||||||
|
diagnosis_id: diagnosisId.value,
|
||||||
|
refresh: refresh ? 1 : 0,
|
||||||
|
...(refresh ? { _t: Date.now() } : {})
|
||||||
|
}
|
||||||
|
}, false)
|
||||||
|
if (res?.code === 1 && res.data) {
|
||||||
|
applyDietRecommendPayload(res.data)
|
||||||
|
} else if (res?.msg) {
|
||||||
|
showUserToast(res.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDietAiRecommend(refresh = false) {
|
||||||
|
if (!diagnosisId.value || dietAiLoading.value) return
|
||||||
|
dietAiLoading.value = true
|
||||||
|
dietAiStreaming.value = false
|
||||||
|
dietAiReplacing.value = !!refresh
|
||||||
|
dietStreamBuffer = ''
|
||||||
|
dietAiStreamField.value = ''
|
||||||
|
if (refresh) {
|
||||||
|
applyDietRecommendPayload(emptyDietAiRecommend())
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestAiStream({
|
||||||
|
baseUrl: proxy.$url,
|
||||||
|
url: '/api/tcm/dailyDietAiRecommendStream',
|
||||||
|
method: 'GET',
|
||||||
|
data: {
|
||||||
|
diagnosis_id: diagnosisId.value,
|
||||||
|
refresh: refresh ? 1 : 0,
|
||||||
|
...(refresh ? { _t: Date.now() } : {})
|
||||||
|
},
|
||||||
|
onEvent: onDietAiStreamEvent,
|
||||||
|
fallback: () => fetchDietAiRecommendFallback(refresh)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await fetchDietAiRecommendFallback(refresh)
|
||||||
|
} catch (e2) {
|
||||||
|
showUserToast('饮食建议加载失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dietAiLoading.value = false
|
||||||
|
dietAiStreaming.value = false
|
||||||
|
dietAiReplacing.value = false
|
||||||
|
dietAiStreamField.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDietAiRecommend() {
|
||||||
|
if (dietAiLoading.value) return
|
||||||
|
fetchDietAiRecommend(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askDietAiFoodFallback(q) {
|
||||||
|
const res = await proxy.apiUrl({
|
||||||
|
url: '/api/tcm/dailyDietAiAsk',
|
||||||
|
method: 'POST',
|
||||||
|
timeout: 35000,
|
||||||
|
data: {
|
||||||
|
diagnosis_id: diagnosisId.value,
|
||||||
|
question: q
|
||||||
|
}
|
||||||
|
}, false)
|
||||||
|
if (res?.code === 1 && res.data) {
|
||||||
|
dietAiAskResult.value = {
|
||||||
|
advice: res.data.advice || '',
|
||||||
|
level: res.data.level || '',
|
||||||
|
level_label: res.data.level_label || '',
|
||||||
|
portion: res.data.portion || '',
|
||||||
|
food: res.data.food || q
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showUserToast(formatUserMessage(res?.msg, '咨询失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askDietAiFood() {
|
||||||
|
const q = String(dietAiAskText.value || '').trim()
|
||||||
|
if (!q) {
|
||||||
|
showUserToast('请输入想咨询的食物')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!diagnosisId.value) return
|
||||||
|
// 发送后立即清空输入框
|
||||||
|
dietAiAskText.value = ''
|
||||||
|
dietAiAsking.value = true
|
||||||
|
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
||||||
|
try {
|
||||||
|
await requestAiStream({
|
||||||
|
baseUrl: proxy.$url,
|
||||||
|
url: '/api/tcm/dailyDietAiAskStream',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
diagnosis_id: diagnosisId.value,
|
||||||
|
question: q
|
||||||
|
},
|
||||||
|
onEvent: (event, payload) => {
|
||||||
|
if (event === 'start' && !dietAiAskResult.value.advice) {
|
||||||
|
dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' }
|
||||||
|
} else if (event === 'delta') {
|
||||||
|
dietAiAskResult.value = {
|
||||||
|
...dietAiAskResult.value,
|
||||||
|
advice: payload.advice || dietAiAskResult.value.advice
|
||||||
|
}
|
||||||
|
} else if (event === 'done') {
|
||||||
|
dietAiAskResult.value = {
|
||||||
|
advice: payload.advice || '',
|
||||||
|
level: payload.level || '',
|
||||||
|
level_label: payload.level_label || '',
|
||||||
|
portion: payload.portion || '',
|
||||||
|
food: payload.food || q
|
||||||
|
}
|
||||||
|
} else if (event === 'error') {
|
||||||
|
showUserToast(formatUserMessage(payload?.message, '咨询失败'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fallback: () => askDietAiFoodFallback(q)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await askDietAiFoodFallback(q)
|
||||||
|
} catch (e2) {
|
||||||
|
showUserToast('网络异常,请稍后再试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dietAiAsking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDietAiAskInput(e) {
|
||||||
|
dietAiAskText.value = e?.detail?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDietPrefillToStorage() {
|
||||||
|
const r = dietAiRecommend.value
|
||||||
|
if (!r.breakfast) return
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
||||||
|
breakfast: r.breakfast,
|
||||||
|
drinks: r.drinks || '',
|
||||||
|
lunch: r.lunch,
|
||||||
|
dinner: r.dinner,
|
||||||
|
tips: r.tips || ''
|
||||||
|
})
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goMoreDietForm(withPrefill = false) {
|
||||||
|
if (withPrefill) saveDietPrefillToStorage()
|
||||||
|
uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDietAiToForm() {
|
||||||
|
if (!dietAiRecommend.value.breakfast) {
|
||||||
|
await fetchDietAiRecommend(false)
|
||||||
|
}
|
||||||
|
goMoreDietForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDietForm() {
|
||||||
|
goMoreDietForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dietAiRecommend,
|
||||||
|
dietAiLoading,
|
||||||
|
dietAiStreaming,
|
||||||
|
dietAiReplacing,
|
||||||
|
dietAiStreamField,
|
||||||
|
dietAiAskText,
|
||||||
|
dietAiAsking,
|
||||||
|
dietAiAskResult,
|
||||||
|
hasDietAiMealContent,
|
||||||
|
fetchDietAiRecommend,
|
||||||
|
refreshDietAiRecommend,
|
||||||
|
askDietAiFood,
|
||||||
|
onDietAiAskInput,
|
||||||
|
applyDietAiToForm,
|
||||||
|
openDietForm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** more 页打开饮食表单时读取并清除预填 */
|
||||||
|
export function consumeDietAiPrefill() {
|
||||||
|
try {
|
||||||
|
const data = uni.getStorageSync(DIET_AI_PREFILL_KEY)
|
||||||
|
uni.removeStorageSync(DIET_AI_PREFILL_KEY)
|
||||||
|
if (data && typeof data === 'object' && data.breakfast) return data
|
||||||
|
} catch (e) {}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -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,115 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { formatUserMessage } from '../utils/tongjiHelpers.js'
|
||||||
|
|
||||||
|
/** 登录 / 就诊卡门禁(index 与 more 共用) */
|
||||||
|
export function useTongjiAuth(proxy) {
|
||||||
|
const authChecking = ref(false)
|
||||||
|
let gateRedirected = false
|
||||||
|
let authSessionPromise = null
|
||||||
|
|
||||||
|
function hasAuthToken() {
|
||||||
|
return !!String(uni.getStorageSync('token') || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthStorage() {
|
||||||
|
uni.removeStorageSync('token')
|
||||||
|
authSessionPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function wxLoginGetCode() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.login({
|
||||||
|
provider: 'weixin',
|
||||||
|
success: (res) => {
|
||||||
|
if (res && res.code) resolve(res.code)
|
||||||
|
else reject(new Error('微信登录未返回 code'))
|
||||||
|
},
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mnpLoginWithCode(code) {
|
||||||
|
const res = await proxy.apiUrl({
|
||||||
|
url: '/api/login/mnpLogin',
|
||||||
|
method: 'POST',
|
||||||
|
data: { code }
|
||||||
|
}, false)
|
||||||
|
if (res && res.code === 1 && res.data && res.data.token) {
|
||||||
|
uni.setStorageSync('token', res.data.token)
|
||||||
|
uni.setStorageSync('userData', res.data)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
clearAuthStorage()
|
||||||
|
throw new Error(formatUserMessage(res?.msg, '登录失败'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doWxLogin() {
|
||||||
|
const code = await wxLoginGetCode()
|
||||||
|
await mnpLoginWithCode(code)
|
||||||
|
return hasAuthToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOrLogin() {
|
||||||
|
if (!hasAuthToken()) {
|
||||||
|
return doWxLogin()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||||||
|
if (res && res.code === 1 && res.data) {
|
||||||
|
uni.setStorageSync('userData', res.data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* 网络异常走重新登录 */
|
||||||
|
}
|
||||||
|
clearAuthStorage()
|
||||||
|
try {
|
||||||
|
return await doWxLogin()
|
||||||
|
} catch (e2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLoggedIn() {
|
||||||
|
if (authSessionPromise) {
|
||||||
|
return authSessionPromise
|
||||||
|
}
|
||||||
|
authSessionPromise = verifyOrLogin()
|
||||||
|
.then((ok) => {
|
||||||
|
if (!ok) authSessionPromise = null
|
||||||
|
return !!ok
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
authSessionPromise = null
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return authSessionPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToCardEntry(returnPath) {
|
||||||
|
if (gateRedirected) return
|
||||||
|
gateRedirected = true
|
||||||
|
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
|
||||||
|
uni.redirectTo({
|
||||||
|
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGateRedirect() {
|
||||||
|
gateRedirected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGateRedirected() {
|
||||||
|
return gateRedirected
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authChecking,
|
||||||
|
hasAuthToken,
|
||||||
|
ensureLoggedIn,
|
||||||
|
redirectToCardEntry,
|
||||||
|
resetGateRedirect,
|
||||||
|
isGateRedirected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# VitalMint Health (Stitch)
|
||||||
|
|
||||||
|
Source: Google Stitch MCP · project `Modern WeChat UI Redesign`
|
||||||
|
Screen HTML: `design-system/stitch-weekly.html`
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
- background / surface: `#f4fbf4`
|
||||||
|
- primary: `#006c49`
|
||||||
|
- primary-container: `#10b981`
|
||||||
|
- tertiary-container (餐后): `#fc7c78`
|
||||||
|
- on-surface: `#161d19`
|
||||||
|
- error (high glucose): `#ba1a1a`
|
||||||
|
- warning: `#ea580c`
|
||||||
|
|
||||||
|
## Layout (weekly.vue)
|
||||||
|
|
||||||
|
1. TopAppBar — 头像 + 问候 + 朗读按钮
|
||||||
|
2. 录入今日血糖 — 全宽圆角主按钮
|
||||||
|
3. 今日血糖 — 双列卡片 + 查看更多
|
||||||
|
4. 血糖趋势 — 单卡片内含统计、图表、7/30 切换
|
||||||
|
5. AI 饮食 — 渐变边框白底卡片
|
||||||
|
6. 能不能吃 — 独立卡片
|
||||||
|
|
||||||
|
## Styles
|
||||||
|
|
||||||
|
- `styles/stitch-vitalmint-theme.scss` — 设计 token
|
||||||
|
- `styles/weekly-stitch.scss` — 页面组件样式 (vm-*)
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<!DOCTYPE html><html lang="zh-CN" style=""><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<title>血糖管理</title>
|
||||||
|
<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 src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
"colors": {
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"on-secondary-fixed-variant": "#3e4943",
|
||||||
|
"on-background": "#161d19",
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"surface-container-highest": "#dde4dd",
|
||||||
|
"surface-container-high": "#e3eae3",
|
||||||
|
"primary-fixed-dim": "#4edea3",
|
||||||
|
"on-secondary-container": "#5b6760",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"inverse-surface": "#2b322d",
|
||||||
|
"tertiary-fixed-dim": "#ffb3af",
|
||||||
|
"primary": "#006c49",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"on-primary-fixed-variant": "#005236",
|
||||||
|
"on-surface-variant": "#3c4a42",
|
||||||
|
"surface-container": "#e8f0e9",
|
||||||
|
"tertiary-container": "#fc7c78",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"on-tertiary-container": "#711419",
|
||||||
|
"surface-container-low": "#eef6ee",
|
||||||
|
"on-secondary-fixed": "#131e19",
|
||||||
|
"inverse-primary": "#4edea3",
|
||||||
|
"surface-tint": "#006c49",
|
||||||
|
"secondary-fixed-dim": "#bdcac1",
|
||||||
|
"on-primary-container": "#00422b",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"secondary-container": "#d9e6dd",
|
||||||
|
"tertiary": "#a43a3a",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"secondary-fixed": "#d9e6dd",
|
||||||
|
"background": "#f4fbf4",
|
||||||
|
"tertiary-fixed": "#ffdad7",
|
||||||
|
"outline": "#6c7a71",
|
||||||
|
"primary-fixed": "#6ffbbe",
|
||||||
|
"on-tertiary-fixed": "#410005",
|
||||||
|
"outline-variant": "#bbcabf",
|
||||||
|
"primary-container": "#10b981",
|
||||||
|
"on-tertiary-fixed-variant": "#842225",
|
||||||
|
"surface-variant": "#dde4dd",
|
||||||
|
"on-primary-fixed": "#002113",
|
||||||
|
"on-surface": "#161d19",
|
||||||
|
"surface-bright": "#f4fbf4",
|
||||||
|
"inverse-on-surface": "#ebf3eb",
|
||||||
|
"surface": "#f4fbf4",
|
||||||
|
"surface-dim": "#d4dcd5",
|
||||||
|
"secondary": "#55615a",
|
||||||
|
"error": "#ba1a1a"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0.25rem",
|
||||||
|
"lg": "0.5rem",
|
||||||
|
"xl": "0.75rem",
|
||||||
|
"full": "9999px",
|
||||||
|
"2xl": "1.5rem",
|
||||||
|
"3xl": "2rem"
|
||||||
|
},
|
||||||
|
"spacing": {
|
||||||
|
"container-margin": "20px",
|
||||||
|
"stack-gap": "16px",
|
||||||
|
"section-margin": "32px",
|
||||||
|
"inline-gap": "12px",
|
||||||
|
"card-padding": "20px"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"body-lg": [
|
||||||
|
"Manrope"
|
||||||
|
],
|
||||||
|
"headline-lg": [
|
||||||
|
"Manrope"
|
||||||
|
],
|
||||||
|
"headline-md": [
|
||||||
|
"Manrope"
|
||||||
|
],
|
||||||
|
"display-lg": [
|
||||||
|
"Manrope"
|
||||||
|
],
|
||||||
|
"headline-lg-mobile": [
|
||||||
|
"Manrope"
|
||||||
|
],
|
||||||
|
"label-md": [
|
||||||
|
"Manrope"
|
||||||
|
],
|
||||||
|
"body-md": [
|
||||||
|
"Manrope"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fontSize": {
|
||||||
|
"body-lg": [
|
||||||
|
"16px",
|
||||||
|
{
|
||||||
|
"lineHeight": "24px",
|
||||||
|
"fontWeight": "500"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"headline-lg": [
|
||||||
|
"24px",
|
||||||
|
{
|
||||||
|
"lineHeight": "32px",
|
||||||
|
"letterSpacing": "-0.01em",
|
||||||
|
"fontWeight": "700"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"headline-md": [
|
||||||
|
"20px",
|
||||||
|
{
|
||||||
|
"lineHeight": "28px",
|
||||||
|
"fontWeight": "700"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"display-lg": [
|
||||||
|
"32px",
|
||||||
|
{
|
||||||
|
"lineHeight": "40px",
|
||||||
|
"letterSpacing": "-0.02em",
|
||||||
|
"fontWeight": "800"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"headline-lg-mobile": [
|
||||||
|
"22px",
|
||||||
|
{
|
||||||
|
"lineHeight": "28px",
|
||||||
|
"fontWeight": "700"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label-md": [
|
||||||
|
"12px",
|
||||||
|
{
|
||||||
|
"lineHeight": "16px",
|
||||||
|
"letterSpacing": "0.02em",
|
||||||
|
"fontWeight": "600"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body-md": [
|
||||||
|
"14px",
|
||||||
|
{
|
||||||
|
"lineHeight": "20px",
|
||||||
|
"fontWeight": "400"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
.icon-fill {
|
||||||
|
font-variation-settings: 'FILL' 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-shadow {
|
||||||
|
box-shadow: 0 12px 30px -10px rgba(0, 108, 73, 0.08), 0 4px 10px -4px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-press:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: max(884px, 100dvh);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-on-surface font-body-lg antialiased pb-24 md:pb-0 min-h-screen">
|
||||||
|
<!-- Desktop Sidebar Shell (Hidden on Mobile) -->
|
||||||
|
<div class="hidden md:flex fixed left-0 top-0 h-full w-64 bg-surface-container-lowest border-r border-surface-dim z-50 flex-col">
|
||||||
|
<div class="p-6">
|
||||||
|
<h1 class="text-headline-lg font-headline-lg text-primary">VitalMint</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-4 flex flex-col gap-2 mt-4">
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-container text-on-primary-container font-bold" href="#">
|
||||||
|
<span class="material-symbols-outlined icon-fill">home</span>
|
||||||
|
<span class="">首页</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||||
|
<span class="material-symbols-outlined">add_circle</span>
|
||||||
|
<span class="">记录</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||||
|
<span class="material-symbols-outlined">analytics</span>
|
||||||
|
<span class="">动态</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
|
||||||
|
<span class="material-symbols-outlined">person</span>
|
||||||
|
<span class="">我的</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<main class="md:ml-64 w-full max-w-[1200px] mx-auto min-h-screen flex flex-col md:flex-row">
|
||||||
|
<!-- Left/Main Column: Dashboard -->
|
||||||
|
<div class="flex-1 w-full">
|
||||||
|
<!-- TopAppBar -->
|
||||||
|
<header class="bg-surface dark:bg-surface-dim docked full-width top-0 flex justify-between items-center px-container-margin py-4 w-full sticky z-40">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-primary-container flex items-center justify-center overflow-hidden border-2 border-surface-container-lowest">
|
||||||
|
<span class="material-symbols-outlined text-primary">person</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface dark:text-on-background">下午好,用户16295733</h1>
|
||||||
|
<p class="text-label-md font-label-md text-on-surface-variant opacity-80">今日血糖已记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container dark:hover:bg-surface-container-high transition-colors text-primary dark:text-primary-fixed-dim">
|
||||||
|
<span class="material-symbols-outlined" data-icon="volume_up">volume_up</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<!-- Content Canvas -->
|
||||||
|
<div class="px-container-margin pb-section-margin flex flex-col gap-6 mt-2">
|
||||||
|
<!-- 1. Primary Action (Prominent) -->
|
||||||
|
<section>
|
||||||
|
<button class="w-full bg-primary text-on-primary h-14 rounded-2xl flex items-center justify-center gap-2 text-body-lg font-bold btn-press shadow-[0_8px_16px_rgba(0,108,73,0.2)] transition-all">
|
||||||
|
<span class="material-symbols-outlined icon-fill">add</span>
|
||||||
|
录入今日血糖
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<!-- 2. 血糖趋势 (Trends) - Moved to top -->
|
||||||
|
<section class="bg-surface-container-lowest rounded-3xl p-5 ambient-shadow border border-surface-container-highest/30">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-headline-md font-headline-md text-on-surface">血糖趋势</h2>
|
||||||
|
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-label-md font-label-md text-on-surface-variant">
|
||||||
|
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
|
||||||
|
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary"></div>餐后</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Stats Row -->
|
||||||
|
<div class="flex justify-between items-center bg-surface-container-low rounded-xl p-4 mb-6">
|
||||||
|
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
|
||||||
|
<span class="text-headline-md font-headline-md text-tertiary">1</span>
|
||||||
|
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天偏高</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
|
||||||
|
<span class="text-headline-md font-headline-md text-primary">1</span>
|
||||||
|
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天正常</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center flex-1">
|
||||||
|
<span class="text-headline-md font-headline-md text-on-surface">2</span>
|
||||||
|
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天有记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Faux Chart Area -->
|
||||||
|
<div class="h-[180px] w-full relative flex items-end pt-4 pb-6 border-b border-surface-container-highest border-dashed">
|
||||||
|
<!-- Y Axis -->
|
||||||
|
<div class="absolute left-0 top-0 bottom-6 flex flex-col justify-between text-[10px] text-on-surface-variant">
|
||||||
|
<span class="">23.0</span>
|
||||||
|
<span class="">18.0</span>
|
||||||
|
<span class="">13.0</span>
|
||||||
|
<span class="">8.0</span>
|
||||||
|
<span class="">3.0</span>
|
||||||
|
</div>
|
||||||
|
<!-- Target Line -->
|
||||||
|
<div class="absolute left-6 right-0 bottom-12 border-b border-tertiary border-dashed opacity-30"></div>
|
||||||
|
<span class="absolute left-6 bottom-12 text-[10px] text-tertiary -translate-y-full mb-1">空腹阈 7</span>
|
||||||
|
<!-- Chart Lines -->
|
||||||
|
<div class="absolute inset-0 left-8 overflow-hidden">
|
||||||
|
<div class="absolute bottom-6 right-8 w-1 h-[100px] bg-primary/20 rounded-t-full transform rotate-45 origin-bottom"></div>
|
||||||
|
<div class="absolute bottom-[80px] right-2 w-1 h-[60px] bg-tertiary/20 rounded-t-full transform rotate-12 origin-bottom"></div>
|
||||||
|
<div class="absolute right-[15%] bottom-[15%] w-2 h-2 rounded-full border-[1.5px] border-primary bg-white z-10"></div>
|
||||||
|
<div class="absolute right-[5%] bottom-[75%] w-2 h-2 rounded-full border-[1.5px] border-tertiary bg-white z-10"></div>
|
||||||
|
<!-- Tooltips -->
|
||||||
|
<div class="absolute right-[2%] bottom-[85%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">18</div>
|
||||||
|
<div class="absolute right-[2%] bottom-[65%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">20</div>
|
||||||
|
</div>
|
||||||
|
<!-- X Axis -->
|
||||||
|
<div class="absolute left-8 right-0 bottom-0 flex justify-between text-[10px] text-on-surface-variant translate-y-full pt-2">
|
||||||
|
<span class="">05-23</span>
|
||||||
|
<span class="">05-25</span>
|
||||||
|
<span class="">05-27</span>
|
||||||
|
<span class="">05-29</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- 3. 最新测量 & 概览 (Combined Section) -->
|
||||||
|
<section class="flex flex-col gap-4">
|
||||||
|
<!-- Latest Reading -->
|
||||||
|
<div class="bg-error-container text-on-error-container rounded-3xl p-6 ambient-shadow relative overflow-hidden flex flex-col items-center text-center justify-center min-h-[160px]">
|
||||||
|
<div class="absolute inset-0 opacity-10 pointer-events-none" style="background-image: radial-gradient(circle at 2px 2px, currentColor 1px, transparent 0); background-size: 16px 16px;"></div>
|
||||||
|
<span class="text-label-md font-label-md uppercase tracking-wider mb-1 opacity-90 z-10">最新测量 · 餐后</span>
|
||||||
|
<div class="flex items-baseline gap-1 z-10">
|
||||||
|
<span class="text-[56px] font-extrabold leading-none tracking-tighter">20.00</span>
|
||||||
|
<span class="text-body-lg font-body-lg opacity-80 font-medium">mmol/L</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 inline-flex items-center gap-1.5 bg-white/30 backdrop-blur-sm px-3 py-1 rounded-full z-10">
|
||||||
|
<span class="material-symbols-outlined text-[16px] icon-fill">warning</span>
|
||||||
|
<span class="text-label-md font-label-md font-bold">偏高,请遵医嘱</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Today's Summary -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
|
||||||
|
<span class="text-label-md font-label-md text-on-surface-variant">空腹</span>
|
||||||
|
<div class="flex items-baseline gap-1 text-tertiary">
|
||||||
|
<span class="text-headline-lg font-headline-lg">18.00</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
|
||||||
|
<span class="text-label-md font-label-md text-on-surface-variant">餐后</span>
|
||||||
|
<div class="flex items-baseline gap-1 text-tertiary">
|
||||||
|
<span class="text-headline-lg font-headline-lg">20.00</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- 4. AI 饮食建议 -->
|
||||||
|
<section class="bg-gradient-to-br from-primary/10 to-primary/5 rounded-3xl p-5 border border-primary/10 relative overflow-hidden">
|
||||||
|
<div class="absolute -right-8 -top-8 w-32 h-32 bg-primary-fixed-dim/20 rounded-full blur-2xl"></div>
|
||||||
|
<div class="flex justify-between items-center mb-4 relative z-10">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center">
|
||||||
|
<span class="material-symbols-outlined text-[18px]">temp_preferences_custom</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-headline-md font-headline-md text-on-surface">AI 饮食建议</h3>
|
||||||
|
</div>
|
||||||
|
<button class="text-label-md font-label-md text-primary flex items-center gap-1 bg-white/50 px-3 py-1.5 rounded-full hover:bg-white transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[14px]">refresh</span>
|
||||||
|
换一换
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-md font-body-md text-on-surface-variant mb-4 relative z-10">读您近 7 天 · 30 天血糖,定制今日三餐</p>
|
||||||
|
<div class="flex flex-col gap-3 relative z-10">
|
||||||
|
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="material-symbols-outlined text-orange-400">light_mode</span>
|
||||||
|
<span class="font-bold text-on-surface">早餐</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-md font-body-md text-on-surface-variant">小米粥(少米多水)加煮鸡蛋一个</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="material-symbols-outlined text-primary">eco</span>
|
||||||
|
<span class="font-bold text-on-surface">午餐</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-md font-body-md text-on-surface-variant">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Spacer for bottom nav on mobile -->
|
||||||
|
<section class="sticky bottom-20 md:bottom-6 z-30 px-container-margin md:px-0 mb-4">
|
||||||
|
<div class="bg-white/90 backdrop-blur-md rounded-full p-2 pl-5 flex items-center gap-3 shadow-[0_8px_32px_rgba(0,108,73,0.15)] border border-primary/10">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center">
|
||||||
|
<span class="material-symbols-outlined icon-fill text-[18px]">sparkles</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" placeholder="输入食物名称或健康疑问,咨询 AI..." class="flex-1 bg-transparent border-none focus:ring-0 text-body-md placeholder:text-on-surface-variant/60 py-2">
|
||||||
|
<button class="bg-primary text-on-primary h-10 w-10 rounded-full flex items-center justify-center btn-press shadow-sm">
|
||||||
|
<span class="material-symbols-outlined">send</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section><div class="h-8 md:hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right Column: Secondary Info (Visible on Tablet/Desktop) -->
|
||||||
|
<aside class="hidden lg:flex w-80 flex-col gap-section-margin pt-4 pr-container-margin">
|
||||||
|
<div class="bg-surface-container-lowest rounded-3xl p-6 ambient-shadow">
|
||||||
|
<h3 class="text-headline-md font-headline-md text-on-surface mb-4">运动降糖</h3>
|
||||||
|
<div class="bg-error-container/30 rounded-xl p-4 mb-4">
|
||||||
|
<p class="text-body-md font-body-md text-on-surface">近期血糖偏高,三顿饭后都动一动,最能帮着把糖降下来。</p>
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-col gap-3 text-body-md text-on-surface-variant">
|
||||||
|
<li class="flex gap-2"><span class="font-bold text-tertiary">1</span> <span class="">早饭后快走 15 分钟</span></li>
|
||||||
|
<li class="flex gap-2"><span class="font-bold text-tertiary">2</span> <span class="">午饭后原地踏步 20-30 分钟</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
<!-- BottomNavBar (Mobile Only) -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</body></html>
|
||||||
@@ -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 @@
|
|||||||
|
{"name":"projects/15362588878567114380/screens/259c8c233434481881d0bacddb59fa7f","title":"血糖管理重构版","screenshot":{"name":"projects/15362588878567114380/files/c18dd3e3d7774a16897d78b40073b9f2","downloadUrl":"https://lh3.googleusercontent.com/aida/ADBb0ugpswBosy3eCG_j3kJlxCWpntXQGvRJvMuepINJTcB47Nf4ymWU81mrjex1ICPdAXn-p_JjyUokOtms4zIJpXL49692zt16mIJhEC16nfU9kH5hwAj1s7v3PlAZbUGWjYjPTdfVMaOCveS6Ohuh_IeyRTD6MmdsFZ2-H7Whg6zy4MiZeGLxRw0ckCOrNbTRObtuzk_dlUc49eDGeRagYtuL9xyS6pno5CiwBzSkGp872chdZVn9JOK-2n1P"},"htmlCode":{"name":"projects/15362588878567114380/files/ca56c13fc2d6481d9de60727f7b70f3f","downloadUrl":"https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ8Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpbCiVodG1sXzk5NTVmZjZjNTFkODQxMmY4MzIxNWNkYmIwNWI2MTMzEgsSBxCEnv3unRAYAZIBJAoKcHJvamVjdF9pZBIWQhQxNTM2MjU4ODg3ODU2NzExNDM4MA&filename=&opi=89354086","mimeType":"text/html"},"width":"780","height":"4122","deviceType":"MOBILE"}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
<!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>血糖管理</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<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": {
|
||||||
|
"on-tertiary-fixed": "#410005",
|
||||||
|
"surface-bright": "#f4fbf4",
|
||||||
|
"on-primary-container": "#00422b",
|
||||||
|
"surface-container": "#e8f0e9",
|
||||||
|
"surface-container-high": "#e3eae3",
|
||||||
|
"surface-dim": "#d4dcd5",
|
||||||
|
"primary-container": "#10b981",
|
||||||
|
"on-secondary-fixed": "#131e19",
|
||||||
|
"surface": "#f4fbf4",
|
||||||
|
"on-surface": "#161d19",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"tertiary-container": "#fc7c78",
|
||||||
|
"secondary": "#55615a",
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"on-background": "#161d19",
|
||||||
|
"secondary-fixed-dim": "#bdcac1",
|
||||||
|
"inverse-on-surface": "#ebf3eb",
|
||||||
|
"surface-container-highest": "#dde4dd",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"on-surface-variant": "#3c4a42",
|
||||||
|
"secondary-fixed": "#d9e6dd",
|
||||||
|
"inverse-surface": "#2b322d",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"on-tertiary-fixed-variant": "#842225",
|
||||||
|
"outline-variant": "#bbcabf",
|
||||||
|
"on-primary-fixed-variant": "#005236",
|
||||||
|
"on-secondary-container": "#5b6760",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"primary": "#006c49",
|
||||||
|
"outline": "#6c7a71",
|
||||||
|
"tertiary-fixed": "#ffdad7",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"tertiary": "#a43a3a",
|
||||||
|
"surface-tint": "#006c49",
|
||||||
|
"inverse-primary": "#4edea3",
|
||||||
|
"on-tertiary-container": "#711419",
|
||||||
|
"primary-fixed-dim": "#4edea3",
|
||||||
|
"on-secondary-fixed-variant": "#3e4943",
|
||||||
|
"tertiary-fixed-dim": "#ffb3af",
|
||||||
|
"surface-container-low": "#eef6ee",
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"on-primary-fixed": "#002113",
|
||||||
|
"background": "#f4fbf4",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"primary-fixed": "#6ffbbe",
|
||||||
|
"surface-variant": "#dde4dd",
|
||||||
|
"secondary-container": "#d9e6dd"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0.25rem",
|
||||||
|
"lg": "0.5rem",
|
||||||
|
"xl": "0.75rem",
|
||||||
|
"full": "9999px",
|
||||||
|
"2xl": "1.5rem"
|
||||||
|
},
|
||||||
|
"spacing": {
|
||||||
|
"section-margin": "32px",
|
||||||
|
"stack-gap": "16px",
|
||||||
|
"card-padding": "20px",
|
||||||
|
"inline-gap": "12px",
|
||||||
|
"container-margin": "20px"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"headline-md": ["Manrope"],
|
||||||
|
"label-md": ["Manrope"],
|
||||||
|
"body-lg": ["Manrope"],
|
||||||
|
"headline-lg-mobile": ["Manrope"],
|
||||||
|
"headline-lg": ["Manrope"],
|
||||||
|
"display-lg": ["Manrope"],
|
||||||
|
"body-md": ["Manrope"]
|
||||||
|
},
|
||||||
|
"boxShadow": {
|
||||||
|
'ambient': '0 10px 30px -10px rgba(0, 108, 73, 0.08)',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
.material-symbols-outlined.fill-icon {
|
||||||
|
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
min-height: max(884px, 100dvh);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface text-on-surface font-body-md antialiased md:max-w-[600px] md:mx-auto pb-24">
|
||||||
|
<!-- TopAppBar -->
|
||||||
|
<header class="flex justify-between items-center px-container-margin py-4 w-full bg-surface top-0 sticky z-40">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden">
|
||||||
|
<span class="material-symbols-outlined text-outline">person</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface">下午好,用户16295733</h1>
|
||||||
|
<p class="text-label-md font-label-md text-on-surface-variant">今日血糖已记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="flex flex-col items-center justify-center bg-surface-container-low border border-outline-variant rounded-xl px-3 py-1.5 hover:bg-surface-container transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary mb-0.5">volume_up</span>
|
||||||
|
<span class="text-[10px] font-label-md text-on-surface-variant">朗读血糖</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<main class="px-container-margin flex flex-col gap-section-margin mt-4">
|
||||||
|
<!-- Primary Action -->
|
||||||
|
<section>
|
||||||
|
<button class="w-full bg-primary text-on-primary rounded-full py-4 flex items-center justify-center gap-2 hover:bg-on-primary-fixed-variant transition-colors shadow-ambient active:scale-95 duration-150">
|
||||||
|
<span class="material-symbols-outlined">add</span>
|
||||||
|
<span class="text-body-lg font-body-lg font-bold">录入今日血糖</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<!-- Current Status Cards -->
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-headline-md font-headline-md">今日血糖</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<!-- Fasting -->
|
||||||
|
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
|
||||||
|
<div class="text-label-md font-label-md text-on-surface-variant mb-1">空腹</div>
|
||||||
|
<div class="flex items-baseline gap-1 mb-2">
|
||||||
|
<span class="text-display-lg font-display-lg text-error">18.00</span>
|
||||||
|
<span class="text-label-md font-label-md text-error">mmol/L</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
|
||||||
|
<span class="material-symbols-outlined text-[14px]">warning</span>
|
||||||
|
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Post-meal -->
|
||||||
|
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
|
||||||
|
<div class="text-label-md font-label-md text-on-surface-variant mb-1">餐后</div>
|
||||||
|
<div class="flex items-baseline gap-1 mb-2">
|
||||||
|
<span class="text-display-lg font-display-lg text-error">20.00</span>
|
||||||
|
<span class="text-label-md font-label-md text-error">mmol/L</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
|
||||||
|
<span class="material-symbols-outlined text-[14px]">warning</span>
|
||||||
|
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="w-full flex items-center justify-between bg-surface-container-lowest rounded-xl p-4 shadow-ambient mt-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-body-md font-body-md font-bold">查看更多</div>
|
||||||
|
<div class="text-label-md font-label-md text-on-surface-variant">健康日历 · 运动 · 家人互动</div>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-outline">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<!-- Data Overview: Trend -->
|
||||||
|
<section class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient">
|
||||||
|
<div class="flex justify-between items-end mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-headline-md font-headline-md">血糖趋势</h2>
|
||||||
|
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 text-label-md font-label-md">
|
||||||
|
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
|
||||||
|
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary-container"></div>餐后</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="flex bg-surface-container-low rounded-xl p-3 mb-6 divide-x divide-outline-variant">
|
||||||
|
<div class="flex-1 flex flex-col items-center">
|
||||||
|
<span class="text-headline-md font-headline-md text-error">1</span>
|
||||||
|
<span class="text-label-md font-label-md text-on-surface-variant">天偏高</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex flex-col items-center">
|
||||||
|
<span class="text-headline-md font-headline-md text-primary">1</span>
|
||||||
|
<span class="text-label-md font-label-md text-on-surface-variant">天正常</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex flex-col items-center">
|
||||||
|
<span class="text-headline-md font-headline-md text-on-surface">2</span>
|
||||||
|
<span class="text-label-md font-label-md text-on-surface-variant">天有记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Chart Area Placeholder -->
|
||||||
|
<div class="relative h-48 w-full mb-6 border-b border-l border-outline-variant">
|
||||||
|
<!-- Y Axis Labels -->
|
||||||
|
<div class="absolute -left-6 top-0 bottom-0 flex flex-col justify-between text-[10px] text-on-surface-variant">
|
||||||
|
<span>23.0</span>
|
||||||
|
<span>18.0</span>
|
||||||
|
<span>13.0</span>
|
||||||
|
<span>8.0</span>
|
||||||
|
<span>3.0</span>
|
||||||
|
</div>
|
||||||
|
<!-- X Axis Labels -->
|
||||||
|
<div class="absolute -bottom-5 left-0 right-0 flex justify-between text-[10px] text-on-surface-variant">
|
||||||
|
<span>05-23</span>
|
||||||
|
<span>05-25</span>
|
||||||
|
<span>05-27</span>
|
||||||
|
<span>05-29</span>
|
||||||
|
</div>
|
||||||
|
<!-- Threshold Line -->
|
||||||
|
<div class="absolute bottom-[25%] w-full border-t border-dashed border-error opacity-50">
|
||||||
|
<span class="absolute -top-4 text-[10px] text-error">空腹阈值 7</span>
|
||||||
|
</div>
|
||||||
|
<!-- Abstract Chart representation -->
|
||||||
|
<svg class="absolute inset-0 h-full w-full" preserveaspectratio="none" viewbox="0 0 100 100">
|
||||||
|
<path d="M 80 80 L 95 30" fill="none" stroke="#006c49" stroke-width="2"></path>
|
||||||
|
<path d="M 85 85 L 95 15" fill="none" stroke="#fc7c78" stroke-width="2"></path>
|
||||||
|
<circle cx="80" cy="80" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
|
||||||
|
<circle cx="95" cy="30" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
|
||||||
|
<circle cx="85" cy="85" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
|
||||||
|
<circle cx="95" cy="15" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<!-- Time Toggles -->
|
||||||
|
<div class="flex bg-surface-container-low rounded-full p-1 w-full max-w-[240px] mx-auto mt-8">
|
||||||
|
<button class="flex-1 py-2 text-label-md font-label-md bg-primary text-on-primary rounded-full shadow-sm">最近 7 天</button>
|
||||||
|
<button class="flex-1 py-2 text-label-md font-label-md text-on-surface-variant rounded-full">最近 30 天</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- AI Nutrition Section -->
|
||||||
|
<section class="bg-gradient-to-br from-primary-container/20 to-primary/10 rounded-2xl p-[2px] shadow-ambient overflow-hidden relative">
|
||||||
|
<div class="bg-surface-container-lowest rounded-2xl p-card-padding relative z-10">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||||
|
<span class="material-symbols-outlined text-on-primary text-[18px]">auto_awesome</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-body-lg font-body-lg font-bold">AI 饮食建议</h3>
|
||||||
|
<span class="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full border border-primary/20">智能定制</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-on-surface-variant mt-0.5">读您近 7 天 · 30 天血糖,定制今日三餐</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="flex items-center gap-1 text-label-md font-label-md text-primary bg-primary/10 px-3 py-1.5 rounded-full">
|
||||||
|
<span class="material-symbols-outlined text-[14px]">refresh</span>换一换
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Meals List -->
|
||||||
|
<div class="flex flex-col gap-3 mb-4">
|
||||||
|
<div class="text-label-md font-label-md text-primary font-bold flex items-center gap-1">
|
||||||
|
<div class="w-1 h-3 bg-primary rounded-full"></div> 今日三餐
|
||||||
|
</div>
|
||||||
|
<!-- Breakfast -->
|
||||||
|
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
|
||||||
|
<div class="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<span class="material-symbols-outlined text-tertiary-container text-[16px] fill-icon">wb_sunny</span>
|
||||||
|
<span class="text-label-md font-label-md font-bold">早餐</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-md font-body-md">小米粥(少米多水)加煮鸡蛋一个</p>
|
||||||
|
</div>
|
||||||
|
<!-- Lunch -->
|
||||||
|
<div class="bg-primary/5 p-3 rounded-xl border-l-2 border-primary">
|
||||||
|
<div class="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<span class="material-symbols-outlined text-primary text-[16px] fill-icon">wb_twilight</span>
|
||||||
|
<span class="text-label-md font-label-md font-bold text-primary">午餐</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-md font-body-md">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
|
||||||
|
</div>
|
||||||
|
<!-- Dinner -->
|
||||||
|
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
|
||||||
|
<div class="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<span class="material-symbols-outlined text-[#7e85cc] text-[16px] fill-icon">dark_mode</span>
|
||||||
|
<span class="text-label-md font-label-md font-bold">晚餐</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-md font-body-md">玉米糝粥(稀)配凉拌黄瓜</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tips -->
|
||||||
|
<div class="bg-surface-container-low rounded-xl p-3 mb-4 text-body-md font-body-md text-on-surface-variant flex gap-2">
|
||||||
|
<span class="material-symbols-outlined text-primary text-[16px] mt-0.5">info</span>
|
||||||
|
<p>小贴士:先吃菜和肉再吃主食,每餐七分饱,血糖高就少喝粥汤。</p>
|
||||||
|
</div>
|
||||||
|
<!-- Avoid List -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-1 text-error text-label-md font-label-md mb-2">
|
||||||
|
<span class="material-symbols-outlined text-[14px]">warning</span> 今日尽量少碰
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">白馒头</span>
|
||||||
|
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">油条</span>
|
||||||
|
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">粘豆包</span>
|
||||||
|
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">糯米饭</span>
|
||||||
|
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">西瓜</span>
|
||||||
|
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">含糖饮料</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="flex-1 bg-primary text-on-primary rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-on-primary-fixed-variant transition-colors">填入今日饮食</button>
|
||||||
|
<button class="flex-1 bg-surface-container border border-outline-variant text-on-surface rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-surface-container-high transition-colors">手动记录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Decorative background elements -->
|
||||||
|
<div class="absolute top-0 right-0 w-32 h-32 bg-primary/10 rounded-full blur-2xl -mr-10 -mt-10 pointer-events-none"></div>
|
||||||
|
<div class="absolute bottom-0 left-0 w-24 h-24 bg-tertiary-container/10 rounded-full blur-xl -ml-10 -mb-10 pointer-events-none"></div>
|
||||||
|
</section>
|
||||||
|
<!-- Ask AI -->
|
||||||
|
<section class="bg-surface-container-lowest rounded-2xl p-4 shadow-ambient border border-surface-container-highest">
|
||||||
|
<h3 class="text-body-md font-body-md font-bold mb-1">能不能吃?</h3>
|
||||||
|
<p class="text-[10px] text-on-surface-variant mb-3">输入食物名称,结合您的档案判断</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input class="flex-1 bg-surface rounded-xl border border-outline-variant px-3 py-2 text-body-md focus:ring-2 focus:ring-primary focus:border-primary outline-none" placeholder="如:白馒头、地瓜、粘豆包" type="text"/>
|
||||||
|
<button class="bg-primary text-on-primary px-4 py-2 rounded-xl flex items-center gap-1 hover:bg-on-primary-fixed-variant transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[18px]">auto_awesome</span>
|
||||||
|
<span class="text-body-md font-body-md font-bold">问 AI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Disclaimer -->
|
||||||
|
<footer class="text-center py-6">
|
||||||
|
<p class="text-[10px] text-outline">数据仅供参考,请遵医嘱</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
<!-- BottomNavBar -->
|
||||||
|
<nav class="fixed bottom-0 w-full z-50 rounded-t-2xl shadow-[0_-4px_20px_0_rgba(0,0,0,0.05)] bg-surface-container-lowest md:hidden flex justify-around items-center h-16 px-4">
|
||||||
|
<button class="flex flex-col items-center justify-center text-primary font-bold w-16">
|
||||||
|
<span class="material-symbols-outlined fill-icon mb-1">home</span>
|
||||||
|
<span class="text-[10px] font-label-md">首页</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||||
|
<span class="material-symbols-outlined mb-1">add_circle</span>
|
||||||
|
<span class="text-[10px] font-label-md">记录</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||||
|
<span class="material-symbols-outlined mb-1">analytics</span>
|
||||||
|
<span class="text-[10px] font-label-md">动态</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
|
||||||
|
<span class="material-symbols-outlined mb-1">person</span>
|
||||||
|
<span class="text-[10px] font-label-md">我的</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</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: 61 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,570 @@
|
|||||||
|
#!/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: ['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
|
||||||
|
for (const remaining of [0, 1, 2]) {
|
||||||
|
const state = {
|
||||||
|
run: -1,
|
||||||
|
turn: remaining,
|
||||||
|
rng: createRng(BASE_SEED + groupIndex * 17 + remaining),
|
||||||
|
metrics,
|
||||||
|
themeIndex: nextIndex,
|
||||||
|
activeSafeKeys: [...FOOD_GROUPS[nextIndex].safe],
|
||||||
|
transition: {
|
||||||
|
from: FOOD_GROUPS[groupIndex].safe[0],
|
||||||
|
to: FOOD_GROUPS[nextIndex].safe[0],
|
||||||
|
slot: 0,
|
||||||
|
age: 0
|
||||||
|
},
|
||||||
|
queuedSafeGroups: [[...FOOD_GROUPS[nextIndex].safe]],
|
||||||
|
board: []
|
||||||
|
}
|
||||||
|
const fill = FOOD_GROUPS[nextIndex].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 === FOOD_GROUPS[groupIndex].safe[0])) {
|
||||||
|
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,31 @@
|
|||||||
|
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'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,639 @@
|
|||||||
|
.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-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 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,993 @@
|
|||||||
|
/**
|
||||||
|
|
||||||
|
* 日常记录统一主题 — 与甄养堂小程序 TCM Care 品牌色一致
|
||||||
|
|
||||||
|
* 来源:pages/index/index.vue · tabBar selectedColor #204e2b
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page {
|
||||||
|
|
||||||
|
/* 品牌主色 */
|
||||||
|
|
||||||
|
--primary: #204e2b;
|
||||||
|
|
||||||
|
--primary-dark: #163d22;
|
||||||
|
|
||||||
|
--primary-container: #386641;
|
||||||
|
|
||||||
|
--primary-grad: linear-gradient(160deg, #204e2b 0%, #386641 100%);
|
||||||
|
|
||||||
|
--primary-light: #eef6ef;
|
||||||
|
|
||||||
|
--primary-soft: #cfe8d3;
|
||||||
|
|
||||||
|
--primary-glow: rgba(32, 78, 43, 0.14);
|
||||||
|
|
||||||
|
--on-primary-container: #afe2b3;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--success: #386641;
|
||||||
|
|
||||||
|
--success-light: #eef6ef;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--danger: #dc2626;
|
||||||
|
|
||||||
|
--danger-light: #fef2f2;
|
||||||
|
|
||||||
|
--danger-glow: rgba(220, 38, 38, 0.1);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
|
||||||
|
--warning-light: #fff7ed;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--text-primary: #1b1c1a;
|
||||||
|
|
||||||
|
--text-secondary: #414941;
|
||||||
|
|
||||||
|
--text-muted: #727970;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--surface: #ffffff;
|
||||||
|
|
||||||
|
--surface-muted: #f4f4f0;
|
||||||
|
|
||||||
|
--border: #e3e2df;
|
||||||
|
|
||||||
|
--border-soft: rgba(32, 78, 43, 0.12);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--slate-50: #faf9f5;
|
||||||
|
|
||||||
|
--slate-100: #f4f4f0;
|
||||||
|
|
||||||
|
--slate-200: #e9e8e4;
|
||||||
|
|
||||||
|
--slate-300: #e3e2df;
|
||||||
|
|
||||||
|
--slate-400: #727970;
|
||||||
|
|
||||||
|
--slate-600: #414941;
|
||||||
|
|
||||||
|
--slate-900: #1b1c1a;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--shadow-premium: 0 8rpx 28rpx rgba(32, 78, 43, 0.06);
|
||||||
|
|
||||||
|
--shadow-sm: 0 4rpx 16rpx rgba(32, 78, 43, 0.05);
|
||||||
|
|
||||||
|
--shadow-glow-primary: 0 8rpx 20rpx rgba(32, 78, 43, 0.18);
|
||||||
|
|
||||||
|
--shadow-glow-danger: 0 8rpx 20rpx rgba(220, 38, 38, 0.12);
|
||||||
|
|
||||||
|
--border-premium: 1rpx solid var(--border-soft);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
background: var(--slate-50);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Hero:品牌绿渐变 */
|
||||||
|
|
||||||
|
.daily-page .hero-bg {
|
||||||
|
|
||||||
|
background: var(--primary-grad);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
|
||||||
|
background: radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.1) 0%, transparent 55%);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .hero-orb {
|
||||||
|
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .hero-pill.voice.speaking {
|
||||||
|
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
|
||||||
|
border-color: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
animation: none;
|
||||||
|
|
||||||
|
box-shadow: 0 0 0 3rpx rgba(255, 255, 255, 0.35);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .hero-status-chip.pending {
|
||||||
|
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
|
||||||
|
border-color: rgba(255, 255, 255, 0.28);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .hero-status-chip.done {
|
||||||
|
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
border-color: rgba(255, 255, 255, 0.32);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* 指标卡 */
|
||||||
|
|
||||||
|
.daily-page .stat-fasting,
|
||||||
|
|
||||||
|
.daily-page .stat-postprandial,
|
||||||
|
|
||||||
|
.daily-page .stat-other {
|
||||||
|
|
||||||
|
background: var(--surface);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .stat-fasting .stat-card-tag,
|
||||||
|
|
||||||
|
.daily-page .stat-postprandial .stat-card-tag,
|
||||||
|
|
||||||
|
.daily-page .stat-other .stat-card-tag {
|
||||||
|
|
||||||
|
background: var(--primary-light);
|
||||||
|
|
||||||
|
color: var(--primary-dark);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .stat-card-value {
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
-webkit-background-clip: unset;
|
||||||
|
|
||||||
|
background-clip: unset;
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .stat-card-value.is-high {
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
color: var(--danger);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .stat-card-trend.trend-down {
|
||||||
|
|
||||||
|
background: var(--primary-light);
|
||||||
|
|
||||||
|
.stat-card-trend-arrow,
|
||||||
|
|
||||||
|
.stat-card-trend-delta {
|
||||||
|
|
||||||
|
color: var(--primary-dark);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .more-entry-arrow {
|
||||||
|
|
||||||
|
color: var(--primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .card-chip.active {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-glow-primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .range-item.active {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-glow-primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .legend-dot-fasting {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .legend-dot-postprandial {
|
||||||
|
|
||||||
|
background: var(--warning);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .streak-strip {
|
||||||
|
|
||||||
|
background: var(--surface);
|
||||||
|
|
||||||
|
border-color: var(--border-soft);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
|
||||||
|
background: radial-gradient(circle, rgba(32, 78, 43, 0.08) 0%, transparent 70%);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .streak-num {
|
||||||
|
|
||||||
|
color: var(--primary-dark);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* 日历:品牌绿深浅 */
|
||||||
|
|
||||||
|
.daily-page .calendar-cell.level-1 .calendar-cell-mark,
|
||||||
|
|
||||||
|
.daily-page .calendar-legend-cell.level-1 {
|
||||||
|
|
||||||
|
background: var(--primary-soft);
|
||||||
|
|
||||||
|
border-color: #b8dcc0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .calendar-cell.level-2 .calendar-cell-mark,
|
||||||
|
|
||||||
|
.daily-page .calendar-legend-cell.level-2 {
|
||||||
|
|
||||||
|
background: var(--on-primary-container);
|
||||||
|
|
||||||
|
border-color: #86c992;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .calendar-cell.level-3 .calendar-cell-mark,
|
||||||
|
|
||||||
|
.daily-page .calendar-legend-cell.level-3 {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
border-color: var(--primary-dark);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .day-block-tag.tag-blood,
|
||||||
|
|
||||||
|
.daily-page .day-block-tag.tag-diet,
|
||||||
|
|
||||||
|
.daily-page .day-block-tag.tag-exercise,
|
||||||
|
|
||||||
|
.daily-page .day-block-tag.tag-tracking {
|
||||||
|
|
||||||
|
background: var(--primary-light);
|
||||||
|
|
||||||
|
color: var(--primary-dark);
|
||||||
|
|
||||||
|
border: 1rpx solid var(--border-soft);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .day-block-tag.tag-diet,
|
||||||
|
|
||||||
|
.daily-page .day-block-tag.tag-exercise {
|
||||||
|
|
||||||
|
background: var(--surface-muted);
|
||||||
|
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
border-color: var(--border);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .pg-points-badge {
|
||||||
|
|
||||||
|
background: var(--primary-light);
|
||||||
|
|
||||||
|
border-color: var(--border-soft);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .pg-points-num {
|
||||||
|
|
||||||
|
color: var(--primary-dark);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .record-quick-chip.pending {
|
||||||
|
|
||||||
|
border-color: var(--border);
|
||||||
|
|
||||||
|
background: var(--surface-muted);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .record-quick-chip.done {
|
||||||
|
|
||||||
|
border-color: rgba(56, 102, 65, 0.35);
|
||||||
|
|
||||||
|
background: var(--success-light);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .task-action-btn.pending {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .water-btn {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .family-like-strip,
|
||||||
|
|
||||||
|
.daily-page .encourage-strip {
|
||||||
|
|
||||||
|
background: var(--surface);
|
||||||
|
|
||||||
|
border: 1rpx solid var(--border);
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-premium);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .card-title,
|
||||||
|
|
||||||
|
.daily-page .hero-title {
|
||||||
|
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .report-share-btn,
|
||||||
|
|
||||||
|
.daily-page .report-share-btn::after {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-glow-primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .input-modal-btn.primary {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .input-field-dot-fasting {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .input-field-dot-postprandial {
|
||||||
|
|
||||||
|
background: var(--primary-container);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .input-field-dot-other {
|
||||||
|
|
||||||
|
background: var(--text-muted);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .voice-switch.on .voice-switch-track {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .heatmap-foot-cta {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ========== 适老极简首页(index) ========== */
|
||||||
|
|
||||||
|
.daily-page .elder-header {
|
||||||
|
|
||||||
|
padding: 48rpx 32rpx 24rpx;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-greet {
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
font-size: 44rpx;
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
line-height: 1.35;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-status {
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
margin-top: 16rpx;
|
||||||
|
|
||||||
|
font-size: 32rpx;
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
&.is-done {
|
||||||
|
|
||||||
|
color: var(--success);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
&.is-pending {
|
||||||
|
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-card-scroll {
|
||||||
|
|
||||||
|
margin-top: 24rpx;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-card-row {
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
padding: 2rpx 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-card-chip {
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
|
||||||
|
border-radius: 999rpx;
|
||||||
|
|
||||||
|
background: var(--surface);
|
||||||
|
|
||||||
|
border: 2rpx solid var(--border);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
text {
|
||||||
|
|
||||||
|
font-size: 28rpx;
|
||||||
|
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
border-color: var(--primary);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
text {
|
||||||
|
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-actions {
|
||||||
|
|
||||||
|
padding: 0 32rpx 24rpx;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-btn {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
gap: 12rpx;
|
||||||
|
|
||||||
|
min-height: 112rpx;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
border-radius: 24rpx;
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-btn-text {
|
||||||
|
|
||||||
|
font-size: 36rpx;
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-btn-primary {
|
||||||
|
|
||||||
|
flex: 8;
|
||||||
|
|
||||||
|
background: var(--primary);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.elder-btn-text {
|
||||||
|
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-btn-voice {
|
||||||
|
|
||||||
|
flex: 2;
|
||||||
|
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
padding: 12rpx 8rpx;
|
||||||
|
|
||||||
|
background: var(--surface);
|
||||||
|
|
||||||
|
border: 2rpx solid var(--border-soft);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.elder-btn-text {
|
||||||
|
|
||||||
|
color: var(--primary-dark);
|
||||||
|
|
||||||
|
font-size: 24rpx;
|
||||||
|
|
||||||
|
line-height: 1.25;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
&.speaking {
|
||||||
|
|
||||||
|
border-color: var(--primary);
|
||||||
|
|
||||||
|
background: var(--primary-light);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
|
||||||
|
opacity: 0.45;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-today-panel {
|
||||||
|
|
||||||
|
margin: 0 32rpx 24rpx;
|
||||||
|
|
||||||
|
padding: 32rpx;
|
||||||
|
|
||||||
|
background: var(--surface);
|
||||||
|
|
||||||
|
border-radius: 24rpx;
|
||||||
|
|
||||||
|
border: 1rpx solid var(--border);
|
||||||
|
|
||||||
|
box-shadow: var(--shadow-premium);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-panel-title {
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
font-size: 34rpx;
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-today-grid {
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
|
gap: 20rpx;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-metric {
|
||||||
|
|
||||||
|
padding: 24rpx;
|
||||||
|
|
||||||
|
border-radius: 20rpx;
|
||||||
|
|
||||||
|
background: var(--surface-muted);
|
||||||
|
|
||||||
|
border: 2rpx solid var(--border);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
&.is-high {
|
||||||
|
|
||||||
|
border-color: rgba(220, 38, 38, 0.35);
|
||||||
|
|
||||||
|
background: var(--danger-light);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-metric-label {
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
font-size: 28rpx;
|
||||||
|
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-metric-value-row {
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-metric-value {
|
||||||
|
|
||||||
|
font-size: 56rpx;
|
||||||
|
|
||||||
|
font-weight: 800;
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
line-height: 1.1;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-metric.is-high .elder-metric-value {
|
||||||
|
|
||||||
|
color: var(--danger);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-metric-unit {
|
||||||
|
|
||||||
|
font-size: 26rpx;
|
||||||
|
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-metric-flag {
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
margin-top: 12rpx;
|
||||||
|
|
||||||
|
font-size: 26rpx;
|
||||||
|
|
||||||
|
color: var(--danger);
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-more-link {
|
||||||
|
|
||||||
|
margin: 0 32rpx 16rpx;
|
||||||
|
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
background: var(--surface);
|
||||||
|
|
||||||
|
border-radius: 24rpx;
|
||||||
|
|
||||||
|
border: 1rpx solid var(--border);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-more-title {
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
font-size: 32rpx;
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-more-sub {
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
margin-top: 8rpx;
|
||||||
|
|
||||||
|
font-size: 26rpx;
|
||||||
|
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
line-height: 1.45;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-more-arrow {
|
||||||
|
|
||||||
|
font-size: 48rpx;
|
||||||
|
|
||||||
|
color: var(--primary);
|
||||||
|
|
||||||
|
font-weight: 300;
|
||||||
|
|
||||||
|
padding-left: 16rpx;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .footer-tip {
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
font-size: 26rpx;
|
||||||
|
|
||||||
|
color: var(--text-muted);
|
||||||
|
|
||||||
|
padding: 16rpx 32rpx 48rpx;
|
||||||
|
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.daily-page .elder-chart-block .range-bar {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-page .glucose-history-block {
|
||||||
|
|
||||||
|
padding-bottom: 8rpx;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Google Stitch MCP · VitalMint Health
|
||||||
|
* 设计 token 与 stitch-weekly.html tailwind.config 一一对应
|
||||||
|
*/
|
||||||
|
|
||||||
|
.stitch-vitalmint {
|
||||||
|
--primary: #006c49;
|
||||||
|
--on-primary: #ffffff;
|
||||||
|
--on-primary-fixed-variant: #005236;
|
||||||
|
--primary-container: #10b981;
|
||||||
|
--on-primary-container: #00422b;
|
||||||
|
--inverse-primary: #4edea3;
|
||||||
|
|
||||||
|
--surface: #f4fbf4;
|
||||||
|
--on-surface: #161d19;
|
||||||
|
--on-surface-variant: #3c4a42;
|
||||||
|
--surface-container-lowest: #ffffff;
|
||||||
|
--surface-container-low: #eef6ee;
|
||||||
|
--surface-container: #e8f0e9;
|
||||||
|
--surface-container-high: #e3eae3;
|
||||||
|
--surface-container-highest: #dde4dd;
|
||||||
|
--surface-variant: #dde4dd;
|
||||||
|
|
||||||
|
--outline: #6c7a71;
|
||||||
|
--outline-variant: #bbcabf;
|
||||||
|
|
||||||
|
--error: #ba1a1a;
|
||||||
|
--on-error-container: #93000a;
|
||||||
|
--error-container: #ffdad6;
|
||||||
|
--tertiary: #a43a3a;
|
||||||
|
--tertiary-container: #fc7c78;
|
||||||
|
--on-tertiary: #ffffff;
|
||||||
|
--on-tertiary-container: #711419;
|
||||||
|
|
||||||
|
--st-container-margin: 40rpx;
|
||||||
|
--st-section-gap: 64rpx;
|
||||||
|
--st-card-padding: 40rpx;
|
||||||
|
--st-stack-gap: 32rpx;
|
||||||
|
--st-inline-gap: 24rpx;
|
||||||
|
--st-radius-2xl: 48rpx;
|
||||||
|
--st-radius-xl: 24rpx;
|
||||||
|
--st-radius-lg: 16rpx;
|
||||||
|
--st-shadow-ambient: 0 20rpx 60rpx -20rpx rgba(0, 108, 73, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page.stitch-vitalmint {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--on-surface);
|
||||||
|
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
/* weekly.vue 录入弹窗 — 沿用 MCP 主色 */
|
||||||
|
|
||||||
|
.weekly-page .input-modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
animation: input-modal-mask-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 750rpx;
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border-radius: 36rpx 36rpx 0 0;
|
||||||
|
padding: 16rpx 0 calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -12rpx 36rpx rgba(15, 23, 42, 0.12);
|
||||||
|
max-height: 88vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: input-modal-rise 0.46s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes input-modal-mask-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes input-modal-rise {
|
||||||
|
from { transform: translate3d(0, 100%, 0); opacity: 0.96; }
|
||||||
|
to { transform: translate3d(0, 0, 0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes input-modal-content-in {
|
||||||
|
from { opacity: 0; transform: translate3d(0, 24rpx, 0); }
|
||||||
|
to { opacity: 1; transform: translate3d(0, 0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-grip {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: var(--surface-container-highest);
|
||||||
|
margin: 0 auto 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12rpx 32rpx 16rpx;
|
||||||
|
border-bottom: 1rpx solid var(--surface-container-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-close {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-close-icon {
|
||||||
|
font-size: 42rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-body {
|
||||||
|
padding: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-error {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding: 20rpx 22rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1rpx solid rgba(220, 38, 38, 0.2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #991b1b;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-tip {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-gender-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-gender-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-gender-opt {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--st-radius-xl);
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border: 2rpx solid var(--outline-variant);
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-gender-opt.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96rpx;
|
||||||
|
line-height: 96rpx;
|
||||||
|
border-radius: var(--st-radius-xl);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-loading {
|
||||||
|
min-height: 520rpx;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 30rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 520rpx;
|
||||||
|
padding: 20rpx 32rpx 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-body-ready {
|
||||||
|
animation: input-modal-content-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-tip {
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
border: 1rpx dashed var(--outline-variant);
|
||||||
|
border-radius: 14rpx;
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-section-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
margin: 18rpx 0 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-bottom: 1rpx solid var(--surface-container-low);
|
||||||
|
min-height: 88rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
width: 180rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: var(--on-surface);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field-dot {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field-dot-fasting {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field-dot-postprandial {
|
||||||
|
background: var(--tertiary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field-dot-other {
|
||||||
|
background: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
min-height: 76rpx;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field-input input {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 34rpx;
|
||||||
|
color: var(--on-surface);
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-placeholder {
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-field-textarea textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 140rpx;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 14rpx;
|
||||||
|
padding: 20rpx 22rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: var(--on-surface);
|
||||||
|
border: 2rpx solid var(--outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 20rpx 32rpx 0;
|
||||||
|
border-top: 1rpx solid var(--surface-container-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 100rpx;
|
||||||
|
border-radius: var(--st-radius-xl);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-btn.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-btn.danger {
|
||||||
|
flex: 0 0 180rpx;
|
||||||
|
background: var(--error-container);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .input-modal-btn.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,981 @@
|
|||||||
|
/**
|
||||||
|
* weekly.vue — 与 code.html (Stitch Modern WeChat UI) 1:1
|
||||||
|
*/
|
||||||
|
|
||||||
|
.weekly-page {
|
||||||
|
--st-shadow-ambient: 0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08), 0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
|
||||||
|
--st-shadow-cta: 0 16rpx 32rpx rgba(0, 108, 73, 0.2);
|
||||||
|
--st-shadow-float: 0 16rpx 64rpx rgba(0, 108, 73, 0.15);
|
||||||
|
--tab-dock-full-offset: calc(220rpx + env(safe-area-inset-bottom));
|
||||||
|
padding-bottom: var(--tab-dock-full-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Header ===== */
|
||||||
|
.weekly-page .st-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: var(--st-container-margin);
|
||||||
|
padding-right: var(--st-container-margin);
|
||||||
|
padding-bottom: 32rpx;
|
||||||
|
/* padding-top / padding-right 由 initHeaderSafeArea 动态设置 */
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-header-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--st-inline-gap);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-container);
|
||||||
|
border: 4rpx solid var(--surface-container-lowest);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-greet {
|
||||||
|
display: block;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.27;
|
||||||
|
color: var(--on-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-status {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-voice-round {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-voice-round.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-card-scroll {
|
||||||
|
margin: 0 var(--st-container-margin) 8rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-card-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-card-chip {
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border: 1rpx solid var(--outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-card-chip.active {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 居家训练(DEV 内嵌) ===== */
|
||||||
|
.weekly-page .st-training-section {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-training-section .dev-entry-inline--weekly {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-training-section .inline-scroll {
|
||||||
|
margin-left: -4rpx;
|
||||||
|
margin-right: -4rpx;
|
||||||
|
width: calc(100% + 8rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-training-section .inline-card:first-child {
|
||||||
|
border-color: rgba(0, 108, 73, 0.22);
|
||||||
|
background: linear-gradient(160deg, rgba(0, 108, 73, 0.08) 0%, #f4f7f5 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Main ===== */
|
||||||
|
.weekly-page .st-main {
|
||||||
|
padding: 0 var(--st-container-margin) 32rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 48rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-daily-context-error {
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1rpx solid rgba(220, 38, 38, 0.22);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-daily-context-error-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #991b1b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-main-spacer {
|
||||||
|
height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA — rounded-2xl h-14 */
|
||||||
|
.weekly-page .st-cta {
|
||||||
|
width: 100%;
|
||||||
|
height: 112rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
border-radius: var(--st-radius-2xl);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: var(--st-shadow-cta);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-cta:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 游戏入口 */
|
||||||
|
.weekly-page .st-game-entry {
|
||||||
|
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24rpx;
|
||||||
|
border-radius: var(--st-radius-2xl);
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border: 1rpx solid rgba(0, 108, 73, 0.14);
|
||||||
|
box-shadow: var(--st-shadow-ambient);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-game-entry:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-game-entry-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-game-entry-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-game-entry-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-game-entry-sub {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--on-surface-variant, #64748b);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Chart card — rounded-3xl ===== */
|
||||||
|
.weekly-page .st-chart-card {
|
||||||
|
padding: 40rpx 10rpx 40rpx 10rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border: 1rpx solid rgba(221, 228, 221, 0.3);
|
||||||
|
box-shadow: var(--st-shadow-ambient);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-more-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-more:active {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--st-inline-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-legend-item.inactive {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-dot-fasting {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-dot-post {
|
||||||
|
background: var(--tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-summary {
|
||||||
|
display: flex;
|
||||||
|
padding: 32rpx;
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
border-radius: var(--st-radius-xl);
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-summary-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
border-right: 1rpx solid var(--surface-container-highest);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-summary-item:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-summary-num {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-summary-num.is-high {
|
||||||
|
color: var(--tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-summary-num.is-good {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-summary-label {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-foot {
|
||||||
|
padding: 20rpx 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-mock-hint {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--outline);
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-area {
|
||||||
|
position: relative;
|
||||||
|
height: 360rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-area:active {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 360rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
|
||||||
|
.weekly-page .st-chart-card .st-game-entry-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin: 12rpx 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-card .st-game-entry--in-card {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-sub {
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-chart-empty,
|
||||||
|
.weekly-page .st-chart-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 最新测量 ===== */
|
||||||
|
.weekly-page .st-latest-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 320rpx;
|
||||||
|
padding: 48rpx 40rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
box-shadow: var(--st-shadow-ambient);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-hero.is-high {
|
||||||
|
background: var(--error-container);
|
||||||
|
color: var(--on-error-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-hero-pattern {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.1;
|
||||||
|
background-image: radial-gradient(circle at 4rpx 4rpx, currentColor 2rpx, transparent 0);
|
||||||
|
background-size: 32rpx 32rpx;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-value-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-value {
|
||||||
|
font-size: 112rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-hero.is-high .st-latest-value,
|
||||||
|
.weekly-page .st-latest-hero.is-high .st-latest-unit {
|
||||||
|
color: var(--on-error-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-unit {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-latest-flag {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-summary-card {
|
||||||
|
padding: 32rpx;
|
||||||
|
border-radius: var(--st-radius-2xl);
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border: 1rpx solid rgba(221, 228, 221, 0.5);
|
||||||
|
box-shadow: var(--st-shadow-ambient);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-summary-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-summary-value {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-summary-value.is-high {
|
||||||
|
color: var(--tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-summary-tag {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tertiary);
|
||||||
|
background: rgba(255, 218, 214, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== AI 饮食 ===== */
|
||||||
|
.weekly-page .st-ai-section {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 40rpx;
|
||||||
|
border-radius: 48rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 108, 73, 0.1) 0%, rgba(0, 108, 73, 0.05) 100%);
|
||||||
|
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-deco {
|
||||||
|
position: absolute;
|
||||||
|
width: 256rpx;
|
||||||
|
height: 256rpx;
|
||||||
|
top: -64rpx;
|
||||||
|
right: -64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(78, 222, 163, 0.2);
|
||||||
|
filter: blur(40rpx);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-head-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-icon.thinking {
|
||||||
|
animation: st-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes st-pulse {
|
||||||
|
50% { opacity: 0.65; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-refresh {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-refresh.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-ai-sub {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.43;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-meal-list {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-meal-glass {
|
||||||
|
padding: 32rpx;
|
||||||
|
border-radius: var(--st-radius-2xl);
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border: 1rpx solid #ffffff;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-meal-glass--drinks {
|
||||||
|
background: rgba(224, 242, 254, 0.65);
|
||||||
|
border-color: rgba(14, 165, 233, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-meal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-meal-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-meal-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.43;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-skeleton-line {
|
||||||
|
height: 28rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-skeleton-hint {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 底部浮动输入 ===== */
|
||||||
|
.weekly-page .st-float-ask {
|
||||||
|
position: fixed;
|
||||||
|
left: var(--st-container-margin);
|
||||||
|
right: var(--st-container-margin);
|
||||||
|
bottom: var(--tab-dock-full-offset);
|
||||||
|
z-index: 210;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -18rpx;
|
||||||
|
right: -6rpx;
|
||||||
|
width: 44rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-close-icon {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-reopen {
|
||||||
|
position: fixed;
|
||||||
|
right: var(--st-container-margin);
|
||||||
|
bottom: var(--tab-dock-full-offset);
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
|
||||||
|
box-shadow: var(--st-shadow-float);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 210;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 16rpx 16rpx 16rpx 28rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(20rpx);
|
||||||
|
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||||
|
box-shadow: var(--st-shadow-float);
|
||||||
|
transition: border-radius 0.22s ease, padding 0.22s ease, border-color 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 聚焦/有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
|
||||||
|
.weekly-page .st-float-inner.is-expanded {
|
||||||
|
align-items: flex-end;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 18rpx 16rpx 18rpx 32rpx;
|
||||||
|
border-color: rgba(0, 108, 73, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-icon {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 64rpx;
|
||||||
|
max-height: 240rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
color: var(--on-surface);
|
||||||
|
background: transparent;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: min-height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 聚焦时输入框变大;内容超长时由 auto-height 继续向上增高,超过上限可滚动 */
|
||||||
|
.weekly-page .st-float-inner.is-expanded .st-float-input {
|
||||||
|
min-height: 104rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-placeholder {
|
||||||
|
color: rgba(60, 74, 66, 0.6);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-mic {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.2s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-mic.listening {
|
||||||
|
background: var(--primary);
|
||||||
|
animation: st-float-mic-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-mic.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-mic:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-hold-hint {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: var(--primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-voice-overlay {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.32);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-voice-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 56rpx 64rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: rgba(20, 24, 22, 0.86);
|
||||||
|
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-voice-icon {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-voice-icon.active {
|
||||||
|
animation: st-voice-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes st-voice-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.45); }
|
||||||
|
50% { box-shadow: 0 0 0 20rpx rgba(0, 108, 73, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-voice-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-voice-sub {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes st-float-mic-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.35); }
|
||||||
|
50% { box-shadow: 0 0 0 12rpx rgba(0, 108, 73, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-send {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.2);
|
||||||
|
line-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-send.disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-send:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-result {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-radius: var(--st-radius-xl);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 1rpx solid var(--outline-variant);
|
||||||
|
box-shadow: var(--st-shadow-ambient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-float-advice {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .ai-stream-cursor {
|
||||||
|
animation: st-blink 0.8s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes st-blink {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .chart-tip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(22, 29, 25, 0.88);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .chart-tip-date {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .chart-tip-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/** 拼接 API 根路径与相对路径,避免双斜杠 */
|
||||||
|
export function joinApiUrl(base, path) {
|
||||||
|
const b = String(base || '').replace(/\/+$/, '')
|
||||||
|
const p = String(path || '').replace(/^\/+/, '')
|
||||||
|
return p ? `${b}/${p}` : b
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQuery(url, data) {
|
||||||
|
if (!data || typeof data !== 'object') return url
|
||||||
|
const keys = Object.keys(data)
|
||||||
|
if (!keys.length) return url
|
||||||
|
const qs = keys
|
||||||
|
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(data[k] ?? '')}`)
|
||||||
|
.join('&')
|
||||||
|
return url + (url.includes('?') ? '&' : '?') + qs
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeChunkData(data) {
|
||||||
|
if (typeof data === 'string') return data
|
||||||
|
if (!data) return ''
|
||||||
|
if (typeof TextDecoder !== 'undefined' && data instanceof ArrayBuffer) {
|
||||||
|
return new TextDecoder('utf-8').decode(new Uint8Array(data))
|
||||||
|
}
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(data)
|
||||||
|
let str = ''
|
||||||
|
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i])
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(escape(str))
|
||||||
|
} catch (e) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 SSE 文本缓冲中解析并消费完整事件 */
|
||||||
|
export function consumeSseBuffer(buffer, onEvent) {
|
||||||
|
let rest = buffer
|
||||||
|
let idx = rest.indexOf('\n\n')
|
||||||
|
while (idx !== -1) {
|
||||||
|
const block = rest.slice(0, idx)
|
||||||
|
rest = rest.slice(idx + 2)
|
||||||
|
let event = 'message'
|
||||||
|
let dataLine = ''
|
||||||
|
block.split('\n').forEach((line) => {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||||
|
else if (line.startsWith('data:')) dataLine += line.slice(5).trim()
|
||||||
|
})
|
||||||
|
if (dataLine) {
|
||||||
|
try {
|
||||||
|
onEvent(event, JSON.parse(dataLine))
|
||||||
|
} catch (e) {
|
||||||
|
onEvent(event, dataLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = rest.indexOf('\n\n')
|
||||||
|
}
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从流式纯文本中提取三餐(支持未写完的最后一行) */
|
||||||
|
export function parsePartialDietPlainText(text) {
|
||||||
|
const out = {}
|
||||||
|
if (!text) return out
|
||||||
|
|
||||||
|
const lineRe = /^(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm
|
||||||
|
let match
|
||||||
|
while ((match = lineRe.exec(text)) !== null) {
|
||||||
|
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
|
||||||
|
const val = (match[2] || '').trim()
|
||||||
|
if (key === 'avoid') {
|
||||||
|
out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
} else {
|
||||||
|
out[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = text.match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||||
|
if (tail) {
|
||||||
|
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
|
||||||
|
const val = (tail[2] || '').trim()
|
||||||
|
if (key === 'avoid') {
|
||||||
|
if (val) out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
} else {
|
||||||
|
out[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated 使用 parsePartialDietPlainText */
|
||||||
|
export function parsePartialDietJson(text) {
|
||||||
|
return parsePartialDietPlainText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE 流式请求(微信小程序 enableChunked;其它端走 fallback)
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.baseUrl
|
||||||
|
* @param {string} opts.url
|
||||||
|
* @param {string} [opts.method]
|
||||||
|
* @param {object} [opts.data]
|
||||||
|
* @param {function(string, object):void} opts.onEvent
|
||||||
|
* @param {function():Promise<object>} [opts.fallback] 不支持流式时的降级请求
|
||||||
|
*/
|
||||||
|
export function requestAiStream(opts) {
|
||||||
|
const {
|
||||||
|
baseUrl,
|
||||||
|
url,
|
||||||
|
method = 'GET',
|
||||||
|
data = {},
|
||||||
|
onEvent,
|
||||||
|
fallback
|
||||||
|
} = opts
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const token = uni.getStorageSync('token') || ''
|
||||||
|
let sseBuffer = ''
|
||||||
|
let requestUrl = joinApiUrl(baseUrl, url)
|
||||||
|
if (method === 'GET') {
|
||||||
|
requestUrl = appendQuery(requestUrl, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = wx.request({
|
||||||
|
url: requestUrl,
|
||||||
|
method,
|
||||||
|
data: method === 'POST' ? data : {},
|
||||||
|
enableChunked: true,
|
||||||
|
timeout: 60000,
|
||||||
|
header: {
|
||||||
|
token,
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
},
|
||||||
|
success: (res) => {
|
||||||
|
if (res && res.data) {
|
||||||
|
sseBuffer += decodeChunkData(res.data)
|
||||||
|
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
|
||||||
|
}
|
||||||
|
resolve(res)
|
||||||
|
},
|
||||||
|
fail: (err) => reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (task && typeof task.onChunkReceived === 'function') {
|
||||||
|
task.onChunkReceived((res) => {
|
||||||
|
sseBuffer += decodeChunkData(res.data)
|
||||||
|
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
|
||||||
|
})
|
||||||
|
} else if (typeof fallback === 'function') {
|
||||||
|
fallback().then(resolve).catch(reject)
|
||||||
|
} else {
|
||||||
|
reject(new Error('当前环境不支持流式请求'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
if (typeof fallback === 'function') {
|
||||||
|
return fallback()
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('当前环境不支持流式请求'))
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
|
||||||
|
*/
|
||||||
|
|
||||||
|
function utf8ToBytes(str) {
|
||||||
|
const encoded = encodeURIComponent(str)
|
||||||
|
const bytes = []
|
||||||
|
for (let i = 0; i < encoded.length; i++) {
|
||||||
|
if (encoded.charCodeAt(i) === 37) {
|
||||||
|
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
bytes.push(encoded.charCodeAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Uint8Array(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes) {
|
||||||
|
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||||
|
return uni.arrayBufferToBase64(bytes.buffer)
|
||||||
|
}
|
||||||
|
if (typeof btoa !== 'undefined') {
|
||||||
|
let binary = ''
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||||
|
let result = ''
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const a = bytes[i]
|
||||||
|
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||||
|
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||||
|
result += chars[a >> 2]
|
||||||
|
result += chars[((a & 3) << 4) | (b >> 4)]
|
||||||
|
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
|
||||||
|
result += i + 2 < bytes.length ? chars[c & 63] : '='
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function svgToDataUrl(svg) {
|
||||||
|
const normalized = String(svg || '').trim()
|
||||||
|
if (!normalized) return ''
|
||||||
|
const base64 = bytesToBase64(utf8ToBytes(normalized))
|
||||||
|
return `data:image/svg+xml;base64,${base64}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
|
||||||
|
export function formatUserMessage(msg, fallback = '') {
|
||||||
|
if (msg == null || msg === '') return fallback
|
||||||
|
if (typeof msg === 'string') return msg
|
||||||
|
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
|
||||||
|
if (Array.isArray(msg)) {
|
||||||
|
const parts = msg.map((item) => {
|
||||||
|
if (item == null) return ''
|
||||||
|
if (typeof item === 'string') return item
|
||||||
|
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
|
||||||
|
if (typeof item === 'object') {
|
||||||
|
return item.msg || item.message || item.error || item.title || item.label || ''
|
||||||
|
}
|
||||||
|
return String(item)
|
||||||
|
}).filter(Boolean)
|
||||||
|
return parts.length ? parts.join(';') : fallback
|
||||||
|
}
|
||||||
|
if (typeof msg === 'object') {
|
||||||
|
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
|
||||||
|
}
|
||||||
|
return String(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showUserToast(title, options = {}) {
|
||||||
|
const text = formatUserMessage(title, '')
|
||||||
|
if (!text) return
|
||||||
|
uni.showToast({
|
||||||
|
title: text,
|
||||||
|
icon: options.icon || 'none',
|
||||||
|
duration: options.duration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date) {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDate(str) {
|
||||||
|
if (!str) return new Date()
|
||||||
|
const [y, m, d] = str.split('-').map(Number)
|
||||||
|
return new Date(y, (m || 1) - 1, d || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNumber(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) && n !== 0 ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekday(dateStr) {
|
||||||
|
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
return w[parseDate(dateStr).getDay()]
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/** 控糖树等级配置(前后端算法需保持一致:每级 50 积分,最高 Lv.9) */
|
||||||
|
export const TREE_MAX_LEVEL = 9
|
||||||
|
export const TREE_XP_PER_LEVEL = 50
|
||||||
|
|
||||||
|
export const TREE_LEVELS = [
|
||||||
|
{ level: 0, name: '种子眠', emoji: '🫘', mood: '困困', desc: '稳糖种子在土里打盹' },
|
||||||
|
{ level: 1, name: '破土芽', emoji: '🌱', mood: '探头', desc: '探出第一抹新绿' },
|
||||||
|
{ level: 2, name: '展两叶', emoji: '🌿', mood: '好奇', desc: '两片嫩叶迎风展' },
|
||||||
|
{ level: 3, name: '小树苗', emoji: '🪴', mood: '精神', desc: '身子骨硬朗起来' },
|
||||||
|
{ level: 4, name: '青枝繁', emoji: '🌳', mood: '茁壮', desc: '枝叶渐密,元气足' },
|
||||||
|
{ level: 5, name: '拔节高', emoji: '🌲', mood: '挺拔', desc: '一节一节往上蹿' },
|
||||||
|
{ level: 6, name: '稳糖冠', emoji: '💚', mood: '沉稳', desc: '树冠成形,习惯成自然' },
|
||||||
|
{ level: 7, name: '初绽香', emoji: '🌸', mood: '开心', desc: '枝头冒出第一朵花' },
|
||||||
|
{ level: 8, name: '漫开花', emoji: '🌺', mood: '灿烂', desc: '花开满枝,越记越稳' },
|
||||||
|
{ level: 9, name: '圆满树', emoji: '🏆', mood: '荣耀', desc: '满级大树,习惯大师' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const WHISPERS_BY_LEVEL = {
|
||||||
|
0: ['种子在睡觉,记一笔就醒啦。', '今天浇第一滴水,芽就要冒出来。'],
|
||||||
|
1: ['破土啦!再坚持几天就长高。', '小芽最喜欢规律的记录了。'],
|
||||||
|
2: ['两片叶子为你鼓掌。', '空腹餐后都记全,我会长得更快。'],
|
||||||
|
3: ['我已经是一棵小树啦。', '家人点赞的时候,我也会发光。'],
|
||||||
|
4: ['枝叶越来越密,您真棒。', '连续记录,我会开出更多叶子。'],
|
||||||
|
5: ['拔节中!习惯比完美更重要。', '再浇一点水,我就更高啦。'],
|
||||||
|
6: ['习惯成自然,树冠成形啦。', '您今天的坚持,小树都记得。'],
|
||||||
|
7: ['开花啦!闻到春天的味道了吗?', '稳糖花只开给坚持的人。'],
|
||||||
|
8: ['满树花香,您已是控糖达人。', '明天继续来,花儿会更艳。'],
|
||||||
|
9: ['满级大树陪您一路稳糖。', '圆满不是终点,习惯才是。']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcTreeFromPoints(points) {
|
||||||
|
const pts = Math.max(0, Number(points) || 0)
|
||||||
|
const level = Math.min(TREE_MAX_LEVEL, Math.floor(pts / TREE_XP_PER_LEVEL))
|
||||||
|
const xpInLevel = level >= TREE_MAX_LEVEL ? TREE_XP_PER_LEVEL : pts % TREE_XP_PER_LEVEL
|
||||||
|
const progress = level >= TREE_MAX_LEVEL ? 100 : Math.round((xpInLevel / TREE_XP_PER_LEVEL) * 100)
|
||||||
|
const meta = TREE_LEVELS[level] || TREE_LEVELS[0]
|
||||||
|
const nextMeta = level < TREE_MAX_LEVEL ? TREE_LEVELS[level + 1] : null
|
||||||
|
const pointsToNext = level >= TREE_MAX_LEVEL ? 0 : TREE_XP_PER_LEVEL - xpInLevel
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
progress,
|
||||||
|
name: meta.name,
|
||||||
|
emoji: meta.emoji,
|
||||||
|
mood: meta.mood,
|
||||||
|
desc: meta.desc,
|
||||||
|
xpInLevel,
|
||||||
|
xpNeed: TREE_XP_PER_LEVEL,
|
||||||
|
pointsToNext,
|
||||||
|
nextName: nextMeta?.name || '',
|
||||||
|
isMax: level >= TREE_MAX_LEVEL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickTreeWhisper(level) {
|
||||||
|
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
|
||||||
|
const list = WHISPERS_BY_LEVEL[lv] || WHISPERS_BY_LEVEL[0]
|
||||||
|
return list[Math.floor(Math.random() * list.length)]
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
|
||||||
|
|
||||||
|
/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */
|
||||||
|
export const TREE_SPECIES_LIST = [
|
||||||
|
{
|
||||||
|
id: 'classic',
|
||||||
|
name: '稳糖灵树',
|
||||||
|
tagline: '经典路线,从种子到圆满',
|
||||||
|
stages: TREE_LEVELS.map((l) => l.emoji)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bonsai',
|
||||||
|
name: '雅韵盆景',
|
||||||
|
tagline: '小巧精致,书桌旁的温柔陪伴',
|
||||||
|
stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pine',
|
||||||
|
name: '苍劲青松',
|
||||||
|
tagline: '挺拔向上,越记越稳',
|
||||||
|
stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sakura',
|
||||||
|
name: '樱花小树',
|
||||||
|
tagline: '坚持打卡,枝头渐开',
|
||||||
|
stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ginkgo',
|
||||||
|
name: '金秋银杏',
|
||||||
|
tagline: '叶色渐变,见证每一天',
|
||||||
|
stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'
|
||||||
|
|
||||||
|
/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */
|
||||||
|
export const DAILY_MAX_TASK_POINTS = 40
|
||||||
|
|
||||||
|
export function getTreeSpecies(id) {
|
||||||
|
return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speciesEmojiAtLevel(speciesId, level) {
|
||||||
|
const sp = getTreeSpecies(speciesId)
|
||||||
|
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
|
||||||
|
return sp.stages[lv] || sp.stages[0] || '🌱'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前树种成长路线图(供选择面板展示) */
|
||||||
|
export function speciesGrowthRoadmap(speciesId) {
|
||||||
|
const sp = getTreeSpecies(speciesId)
|
||||||
|
return TREE_LEVELS.map((meta, i) => {
|
||||||
|
const xpTotal = i * TREE_XP_PER_LEVEL
|
||||||
|
const daysHint = i === 0
|
||||||
|
? 0
|
||||||
|
: Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))
|
||||||
|
return {
|
||||||
|
level: i,
|
||||||
|
name: meta.name,
|
||||||
|
emoji: sp.stages[i] || meta.emoji,
|
||||||
|
desc: meta.desc,
|
||||||
|
xpTotal,
|
||||||
|
daysHint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全勤打卡大约多少天满级 */
|
||||||
|
export function estimateDaysToMaxLevel() {
|
||||||
|
const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL
|
||||||
|
return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* 健康数据语音文本解析。
|
||||||
|
*
|
||||||
|
* 把语音识别得到的中文口语文本解析为结构化字段:
|
||||||
|
* - 血糖(空腹 / 餐后 / 其他)
|
||||||
|
* - 血压(高压 / 低压 / 西药 / 胰岛素)
|
||||||
|
* - 饮食(早 / 午 / 晚餐 + 备注)
|
||||||
|
* - 运动(类型 / 时长 / 强度)
|
||||||
|
*
|
||||||
|
* 兼容中文数字(六点五 / 一百二十 / 一百二)与阿拉伯数字(6.5 / 120)。
|
||||||
|
* 纯前端解析,无网络依赖,识别失败时返回空对象。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CN_DIGIT = {
|
||||||
|
零: 0, 〇: 0, 一: 1, 壹: 1, 幺: 1, 二: 2, 贰: 2, 两: 2,
|
||||||
|
三: 3, 叁: 3, 四: 4, 肆: 4, 五: 5, 伍: 5, 六: 6, 陆: 6,
|
||||||
|
七: 7, 柒: 7, 八: 8, 捌: 8, 九: 9, 玖: 9
|
||||||
|
}
|
||||||
|
const CN_UNIT = { 十: 10, 拾: 10, 百: 100, 佰: 100, 千: 1000, 仟: 1000, 万: 10000, 亿: 100000000 }
|
||||||
|
|
||||||
|
/** 中文整数串 → 数字,例如 "一百二十" → 120、"八十" → 80、"十" → 10、"一百二" → 120 */
|
||||||
|
function cnIntToNumber(s) {
|
||||||
|
let total = 0
|
||||||
|
let section = 0
|
||||||
|
let number = 0
|
||||||
|
let hadUnit = false
|
||||||
|
let lastUnit = 0
|
||||||
|
let sawZeroAfterUnit = false
|
||||||
|
for (const ch of s) {
|
||||||
|
if (CN_DIGIT[ch] !== undefined) {
|
||||||
|
number = CN_DIGIT[ch]
|
||||||
|
if (number === 0) sawZeroAfterUnit = true
|
||||||
|
} else if (CN_UNIT[ch] !== undefined) {
|
||||||
|
hadUnit = true
|
||||||
|
const unit = CN_UNIT[ch]
|
||||||
|
lastUnit = unit
|
||||||
|
sawZeroAfterUnit = false
|
||||||
|
if (unit >= 10000) {
|
||||||
|
section = (section + number) * unit
|
||||||
|
total += section
|
||||||
|
section = 0
|
||||||
|
} else {
|
||||||
|
if (number === 0) number = 1 // 十 = 10
|
||||||
|
section += number * unit
|
||||||
|
}
|
||||||
|
number = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有任何单位且为纯数字串(如 "一二零")时按位拼接更符合口语
|
||||||
|
if (!hadUnit && s.length > 1) {
|
||||||
|
let joined = ''
|
||||||
|
for (const ch of s) {
|
||||||
|
if (CN_DIGIT[ch] !== undefined) joined += CN_DIGIT[ch]
|
||||||
|
}
|
||||||
|
if (joined) return Number(joined)
|
||||||
|
}
|
||||||
|
// 口语省略尾部单位:"一百二" → 120、"一千五" → 1500(避开 "一百零五" 这类带零的)
|
||||||
|
if (number > 0 && lastUnit >= 100 && !sawZeroAfterUnit) {
|
||||||
|
number = number * (lastUnit / 10)
|
||||||
|
}
|
||||||
|
return total + section + number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中文数字串(含小数点)→ 数字,例如 "六点五" → 6.5 */
|
||||||
|
function cnSeqToNumber(seq) {
|
||||||
|
if (seq.includes('点')) {
|
||||||
|
const parts = seq.split('点')
|
||||||
|
const intPart = parts[0]
|
||||||
|
const decPart = parts.slice(1).join('')
|
||||||
|
const intVal = intPart ? cnIntToNumber(intPart) : 0
|
||||||
|
let decStr = ''
|
||||||
|
for (const ch of decPart) {
|
||||||
|
if (CN_DIGIT[ch] !== undefined) decStr += CN_DIGIT[ch]
|
||||||
|
}
|
||||||
|
if (!decStr) return intVal
|
||||||
|
return Number(`${intVal}.${decStr}`)
|
||||||
|
}
|
||||||
|
return cnIntToNumber(seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否包含中文数字字符(用于过滤“点”“重点”等误匹配) */
|
||||||
|
function hasCnDigit(seq) {
|
||||||
|
for (const ch of seq) {
|
||||||
|
if (CN_DIGIT[ch] !== undefined || CN_UNIT[ch] !== undefined) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把文本中的中文数字段落替换为阿拉伯数字 */
|
||||||
|
export function normalizeNumbers(text) {
|
||||||
|
const raw = String(text || '')
|
||||||
|
return raw.replace(/[零〇一壹幺二贰两三叁四肆五伍六陆七柒八捌九玖十拾百佰千仟万亿点]+/g, (m) => {
|
||||||
|
if (!hasCnDigit(m)) return m
|
||||||
|
const n = cnSeqToNumber(m)
|
||||||
|
return Number.isFinite(n) ? String(n) : m
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在文本中找到关键词后紧随的第一个数字 */
|
||||||
|
function numAfter(text, keys) {
|
||||||
|
for (const k of keys) {
|
||||||
|
const i = text.indexOf(k)
|
||||||
|
if (i >= 0) {
|
||||||
|
const rest = text.slice(i + k.length)
|
||||||
|
const m = rest.match(/-?\d+(?:\.\d+)?/)
|
||||||
|
if (m) return m[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关键词后紧随的一段文本(截止到标点或下一个分隔关键词) */
|
||||||
|
function textAfter(text, keys, stopKeys = []) {
|
||||||
|
for (const k of keys) {
|
||||||
|
const i = text.indexOf(k)
|
||||||
|
if (i >= 0) {
|
||||||
|
let rest = text.slice(i + k.length)
|
||||||
|
// 去掉口语连接词
|
||||||
|
rest = rest.replace(/^[是为吃了喝了吃的喝的有打了用了::,,、。\s]+/, '')
|
||||||
|
let end = rest.length
|
||||||
|
const mStop = rest.match(/[,,。.;;!!??\n]/)
|
||||||
|
if (mStop && mStop.index < end) end = mStop.index
|
||||||
|
for (const sk of stopKeys) {
|
||||||
|
const si = rest.indexOf(sk)
|
||||||
|
if (si >= 0 && si < end) end = si
|
||||||
|
}
|
||||||
|
const seg = rest.slice(0, end).trim()
|
||||||
|
if (seg) return seg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析血糖:返回 { fasting_blood_sugar, postprandial_blood_sugar, other_blood_sugar }
|
||||||
|
* 例:"空腹六点五餐后八点二" → { fasting:6.5, postprandial:8.2 }
|
||||||
|
*/
|
||||||
|
export function parseGlucose(raw) {
|
||||||
|
const t = normalizeNumbers(raw)
|
||||||
|
const result = {}
|
||||||
|
const fasting = numAfter(t, ['空腹'])
|
||||||
|
const post = numAfter(t, ['餐后', '饭后'])
|
||||||
|
const other = numAfter(t, ['其他', '随机', '睡前', '凌晨', '夜间', '晚上'])
|
||||||
|
if (fasting) result.fasting_blood_sugar = fasting
|
||||||
|
if (post) result.postprandial_blood_sugar = post
|
||||||
|
if (other) result.other_blood_sugar = other
|
||||||
|
// 没有关键词但只有一个数字 → 视为“其他血糖”
|
||||||
|
if (!fasting && !post && !other) {
|
||||||
|
const nums = t.match(/\d+(?:\.\d+)?/g)
|
||||||
|
if (nums && nums.length === 1) result.other_blood_sugar = nums[0]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析血压:返回 { systolic_pressure, diastolic_pressure, western_medicine, insulin }
|
||||||
|
* 例:"高压一百二低压八十" → { systolic:120, diastolic:80 }
|
||||||
|
*/
|
||||||
|
export function parseBloodPressure(raw) {
|
||||||
|
const t = normalizeNumbers(raw)
|
||||||
|
const result = {}
|
||||||
|
let sys = numAfter(t, ['高压', '收缩压', '收缩'])
|
||||||
|
let dia = numAfter(t, ['低压', '舒张压', '舒张'])
|
||||||
|
if (!sys || !dia) {
|
||||||
|
const nums = (t.match(/\d{2,3}/g) || [])
|
||||||
|
.map(Number)
|
||||||
|
.filter((n) => n >= 30 && n <= 300)
|
||||||
|
if (!sys && !dia && nums.length >= 2) {
|
||||||
|
sys = String(nums[0])
|
||||||
|
dia = String(nums[1])
|
||||||
|
} else if (!sys && nums.length === 1 && nums[0] >= 90) {
|
||||||
|
sys = String(nums[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 高压应不低于低压,顺序异常时交换
|
||||||
|
if (sys && dia && Number(sys) < Number(dia)) {
|
||||||
|
const tmp = sys
|
||||||
|
sys = dia
|
||||||
|
dia = tmp
|
||||||
|
}
|
||||||
|
if (sys) result.systolic_pressure = sys
|
||||||
|
if (dia) result.diastolic_pressure = dia
|
||||||
|
|
||||||
|
const insulin = textAfter(raw, ['胰岛素'], ['高压', '低压', '血压', '西药', '备注'])
|
||||||
|
if (insulin) result.insulin = insulin
|
||||||
|
const western = textAfter(raw, ['西药', '降糖药', '口服药'], ['胰岛素', '高压', '低压', '血压', '备注'])
|
||||||
|
if (western) result.western_medicine = western
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIET_MARKERS = [
|
||||||
|
{ keys: ['早餐', '早饭', '早上', '早点', '早晨'], field: 'breakfast_foods' },
|
||||||
|
{ keys: ['午餐', '午饭', '中午'], field: 'lunch_foods' },
|
||||||
|
{ keys: ['晚餐', '晚饭', '晚上', '夜宵', '夜里'], field: 'dinner_foods' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function cleanFoodSeg(seg) {
|
||||||
|
return String(seg || '')
|
||||||
|
.replace(/^[是为吃了吃的喝了喝的有::,,、。\s]+/, '')
|
||||||
|
.replace(/[。.\s]+$/, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析饮食:返回 { breakfast_foods, lunch_foods, dinner_foods, note }
|
||||||
|
* 例:"早餐鸡蛋粥中午米饭炒青菜晚上面条" → 分别归位
|
||||||
|
* 无餐别关键词时整句写入 note
|
||||||
|
*/
|
||||||
|
export function parseDiet(raw) {
|
||||||
|
const t = String(raw || '').trim()
|
||||||
|
const result = {}
|
||||||
|
const hits = []
|
||||||
|
DIET_MARKERS.forEach((m) => {
|
||||||
|
for (const k of m.keys) {
|
||||||
|
const idx = t.indexOf(k)
|
||||||
|
if (idx >= 0) {
|
||||||
|
hits.push({ idx, field: m.field, klen: k.length })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!hits.length) {
|
||||||
|
if (t) result.note = t
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
hits.sort((a, b) => a.idx - b.idx)
|
||||||
|
hits.forEach((h, i) => {
|
||||||
|
const start = h.idx + h.klen
|
||||||
|
const end = i + 1 < hits.length ? hits[i + 1].idx : t.length
|
||||||
|
const seg = cleanFoodSeg(t.slice(start, end))
|
||||||
|
if (seg && !result[h.field]) result[h.field] = seg
|
||||||
|
})
|
||||||
|
// 第一个餐别关键词之前的内容作为备注
|
||||||
|
const head = cleanFoodSeg(t.slice(0, hits[0].idx))
|
||||||
|
if (head) result.note = head
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXERCISE_FILLERS = [
|
||||||
|
'今天', '我', '做了', '做', '进行了', '进行', '运动了', '运动', '锻炼了', '锻炼',
|
||||||
|
'了', '大概', '左右', '持续', '差不多', '一共', '总共', '的', '走了', '打了', '跑了'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析运动:返回 { exercise_type, duration, intensity }
|
||||||
|
* 例:"散步三十分钟中强度" → { type:'散步', duration:30, intensity:2 }
|
||||||
|
*/
|
||||||
|
export function parseExercise(raw) {
|
||||||
|
const t = normalizeNumbers(raw)
|
||||||
|
const result = {}
|
||||||
|
|
||||||
|
const durMatch = t.match(/(\d+(?:\.\d+)?)\s*(个小时|小时|钟头|时|分钟|分)/)
|
||||||
|
if (durMatch) {
|
||||||
|
const val = parseFloat(durMatch[1])
|
||||||
|
const isHour = /个小时|小时|钟头|时/.test(durMatch[2])
|
||||||
|
result.duration = String(Math.round(isHour ? val * 60 : val))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/高强度|剧烈|很累|大汗|气喘/.test(t)) result.intensity = 3
|
||||||
|
else if (/中强度|中等强度|中等|有点累|微微出汗|微汗/.test(t)) result.intensity = 2
|
||||||
|
else if (/低强度|轻松|轻微|溜达|缓慢/.test(t)) result.intensity = 1
|
||||||
|
|
||||||
|
// 运动类型:移除时长、强度、填充词后剩余文本
|
||||||
|
let type = t
|
||||||
|
if (durMatch) type = type.replace(durMatch[0], '')
|
||||||
|
type = type
|
||||||
|
.replace(/高强度|中强度|中等强度|低强度|剧烈|很累|大汗|气喘|有点累|微微出汗|微汗|轻松|轻微|缓慢/g, '')
|
||||||
|
.replace(/\d+(?:\.\d+)?/g, '')
|
||||||
|
.replace(/[,,。.;;、\s]+/g, '')
|
||||||
|
EXERCISE_FILLERS.forEach((f) => {
|
||||||
|
type = type.split(f).join('')
|
||||||
|
})
|
||||||
|
type = type.trim()
|
||||||
|
if (type && type.length <= 20) result.exercise_type = type
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABELS = {
|
||||||
|
fasting_blood_sugar: '空腹',
|
||||||
|
postprandial_blood_sugar: '餐后',
|
||||||
|
other_blood_sugar: '其他血糖',
|
||||||
|
systolic_pressure: '高压',
|
||||||
|
diastolic_pressure: '低压',
|
||||||
|
western_medicine: '西药',
|
||||||
|
insulin: '胰岛素',
|
||||||
|
breakfast_foods: '早餐',
|
||||||
|
lunch_foods: '午餐',
|
||||||
|
dinner_foods: '晚餐',
|
||||||
|
note: '备注',
|
||||||
|
exercise_type: '运动',
|
||||||
|
duration: '时长',
|
||||||
|
intensity: '强度'
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTENSITY_LABELS = { 1: '低强度', 2: '中强度', 3: '高强度' }
|
||||||
|
|
||||||
|
/** 把解析结果转成可读的反馈文案,例如 "空腹6.5 · 餐后8.2" */
|
||||||
|
export function summarizeParsed(parsed) {
|
||||||
|
const parts = []
|
||||||
|
Object.keys(parsed).forEach((k) => {
|
||||||
|
const label = FIELD_LABELS[k] || k
|
||||||
|
let val = parsed[k]
|
||||||
|
if (k === 'intensity') val = INTENSITY_LABELS[val] || val
|
||||||
|
parts.push(`${label}${val}`)
|
||||||
|
})
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export interface MetronomeOptions {
|
||||||
|
initialBpm?: number
|
||||||
|
bpmMin?: number
|
||||||
|
bpmMax?: number
|
||||||
|
clickSrc?: string
|
||||||
|
accentSrc?: string
|
||||||
|
accentEvery?: number
|
||||||
|
poolSize?: number
|
||||||
|
volume?: number // 节拍音音量 0-1
|
||||||
|
silent?: boolean // 静音模式:只驱动节拍回调,不发声(如握力环只需视觉节拍)
|
||||||
|
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。
|
||||||
|
* 解决两个问题:
|
||||||
|
* 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms)
|
||||||
|
* 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失
|
||||||
|
*/
|
||||||
|
class AudioPool {
|
||||||
|
private list: UniApp.InnerAudioContext[] = []
|
||||||
|
private cursor = 0
|
||||||
|
private warmedUp = false
|
||||||
|
private targetVolume = 1
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private src: string,
|
||||||
|
private size: number,
|
||||||
|
volume = 1,
|
||||||
|
) {
|
||||||
|
this.targetVolume = volume
|
||||||
|
}
|
||||||
|
|
||||||
|
private create() {
|
||||||
|
for (let i = 0; i < this.size; i++) {
|
||||||
|
const ctx = uni.createInnerAudioContext()
|
||||||
|
ctx.src = this.src
|
||||||
|
ctx.obeyMuteSwitch = false
|
||||||
|
ctx.autoplay = false
|
||||||
|
ctx.volume = this.targetVolume
|
||||||
|
this.list.push(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume: number) {
|
||||||
|
this.targetVolume = Math.max(0, Math.min(1, volume))
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.volume = this.targetVolume
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预热:短暂播放,让音频文件下载/解码到内存
|
||||||
|
* 真正首次 play 不会再卡冷启动
|
||||||
|
*/
|
||||||
|
warmUp() {
|
||||||
|
if (this.warmedUp) return
|
||||||
|
if (this.list.length === 0) this.create()
|
||||||
|
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.volume = 0 // 静音预热,避免进入页面时响声
|
||||||
|
ctx.play()
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
ctx.stop()
|
||||||
|
ctx.volume = this.targetVolume // 恢复目标音量
|
||||||
|
} catch (_) {}
|
||||||
|
}, 60)
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
this.warmedUp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (this.list.length === 0) {
|
||||||
|
this.create()
|
||||||
|
this.warmUp()
|
||||||
|
}
|
||||||
|
const ctx = this.list[this.cursor]
|
||||||
|
this.cursor = (this.cursor + 1) % this.list.length
|
||||||
|
try {
|
||||||
|
// 强制停止所有正在播放的音频,避免重叠
|
||||||
|
this.list.forEach(c => {
|
||||||
|
try { c.stop() } catch (_) {}
|
||||||
|
})
|
||||||
|
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
|
||||||
|
ctx.play()
|
||||||
|
} catch (_) {
|
||||||
|
try { ctx.play() } catch (__) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.destroy?.()
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
this.list = []
|
||||||
|
this.warmedUp = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* InnerAudio 兼容本地路径和网络 URL,这里默认走 CDN,跟 BgAudio 保持一致便于维护
|
||||||
|
首次播放会下载 ~3KB,实测 100~300ms 完成,然后小程序会缓存,后续触发零延迟 */
|
||||||
|
const DEFAULT_CLICK_SRC =
|
||||||
|
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128557ef8669.mp3'
|
||||||
|
|
||||||
|
export function useMetronome(options: MetronomeOptions = {}) {
|
||||||
|
const {
|
||||||
|
initialBpm = 80,
|
||||||
|
bpmMin = 40,
|
||||||
|
bpmMax = 240,
|
||||||
|
clickSrc = DEFAULT_CLICK_SRC,
|
||||||
|
accentSrc,
|
||||||
|
poolSize = 4,
|
||||||
|
volume = 1,
|
||||||
|
silent = false,
|
||||||
|
onBeat,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const bpm = ref<number>(initialBpm)
|
||||||
|
const isPlaying = ref<boolean>(false)
|
||||||
|
const beatIndex = ref<number>(0)
|
||||||
|
const isAccent = ref<boolean>(false)
|
||||||
|
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
|
||||||
|
|
||||||
|
const intervalMs = computed(() => 60000 / bpm.value)
|
||||||
|
|
||||||
|
let clickPool: AudioPool | null = null
|
||||||
|
let accentPool: AudioPool | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let nextTickAt = 0
|
||||||
|
|
||||||
|
const ensureAudio = () => {
|
||||||
|
if (silent) return
|
||||||
|
if (!clickPool) {
|
||||||
|
clickPool = new AudioPool(clickSrc, poolSize, volume)
|
||||||
|
clickPool.warmUp()
|
||||||
|
}
|
||||||
|
if (accentSrc && !accentPool) {
|
||||||
|
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)), volume)
|
||||||
|
accentPool.warmUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playSound = (accent: boolean) => {
|
||||||
|
if (accent && accentPool) {
|
||||||
|
accentPool.play()
|
||||||
|
} else if (clickPool) {
|
||||||
|
clickPool.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!isPlaying.value) return
|
||||||
|
|
||||||
|
const every = Math.max(1, accentEveryRef.value)
|
||||||
|
const accent = beatIndex.value % every === 0
|
||||||
|
isAccent.value = accent
|
||||||
|
|
||||||
|
playSound(accent)
|
||||||
|
onBeat?.(beatIndex.value, accent)
|
||||||
|
beatIndex.value++
|
||||||
|
|
||||||
|
nextTickAt += intervalMs.value
|
||||||
|
const nextDelay = Math.max(0, nextTickAt - Date.now())
|
||||||
|
|
||||||
|
timer = setTimeout(tick, nextDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (isPlaying.value) return
|
||||||
|
ensureAudio()
|
||||||
|
isPlaying.value = true
|
||||||
|
beatIndex.value = 0
|
||||||
|
nextTickAt = Date.now()
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setBpm = (val: number) => {
|
||||||
|
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAccentEvery = (n: number) => {
|
||||||
|
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
|
||||||
|
beatIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态更新音频源(用于切换音色)
|
||||||
|
*/
|
||||||
|
const updateAudioSrc = (newClickSrc: string, newAccentSrc?: string) => {
|
||||||
|
const wasPlaying = isPlaying.value
|
||||||
|
if (wasPlaying) stop()
|
||||||
|
|
||||||
|
// 销毁旧的音频池
|
||||||
|
clickPool?.destroy()
|
||||||
|
accentPool?.destroy()
|
||||||
|
clickPool = null
|
||||||
|
accentPool = null
|
||||||
|
|
||||||
|
// 创建新的音频池(静默预热,不播放)
|
||||||
|
clickPool = new AudioPool(newClickSrc, poolSize)
|
||||||
|
if (newAccentSrc) {
|
||||||
|
accentPool = new AudioPool(newAccentSrc, Math.max(2, Math.ceil(poolSize / 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放一次预览音效(适中音量)
|
||||||
|
const previewCtx = uni.createInnerAudioContext()
|
||||||
|
previewCtx.src = newClickSrc
|
||||||
|
previewCtx.obeyMuteSwitch = false
|
||||||
|
previewCtx.volume = 0.4
|
||||||
|
try {
|
||||||
|
previewCtx.play()
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
previewCtx.destroy?.()
|
||||||
|
} catch (_) {}
|
||||||
|
}, 500)
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 恢复播放状态
|
||||||
|
if (wasPlaying) start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热)
|
||||||
|
* 同时再次设置 setInnerAudioOption,防 App.vue 全局设置失效(iOS 静音键)
|
||||||
|
*/
|
||||||
|
const preload = () => {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
try {
|
||||||
|
uni.setInnerAudioOption({
|
||||||
|
obeyMuteSwitch: false,
|
||||||
|
mixWithOther: true,
|
||||||
|
})
|
||||||
|
} catch (_) {}
|
||||||
|
// #endif
|
||||||
|
ensureAudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
clickPool?.destroy()
|
||||||
|
accentPool?.destroy()
|
||||||
|
clickPool = null
|
||||||
|
accentPool = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
bpm,
|
||||||
|
isPlaying,
|
||||||
|
beatIndex,
|
||||||
|
isAccent,
|
||||||
|
intervalMs,
|
||||||
|
accentEvery: accentEveryRef,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
setBpm,
|
||||||
|
setAccentEvery,
|
||||||
|
updateAudioSrc,
|
||||||
|
preload,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
<template>
|
||||||
|
<view class="crush-canvas-container">
|
||||||
|
<canvas
|
||||||
|
type="2d"
|
||||||
|
id="crush"
|
||||||
|
class="crush-canvas"
|
||||||
|
></canvas>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||||||
|
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
|
||||||
|
|
||||||
|
// 碎片(主体飞溅物)
|
||||||
|
interface Debris {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
size: number
|
||||||
|
color: string
|
||||||
|
shape: ParticleShape
|
||||||
|
rotation: number
|
||||||
|
rotationSpeed: number
|
||||||
|
life: number
|
||||||
|
decay: number
|
||||||
|
gravity: number
|
||||||
|
glow: boolean
|
||||||
|
swing: number // 水平摇摆幅度(confetti 彩纸用),0 表示不摇摆
|
||||||
|
age: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 火花(细小高速亮点,带拖尾)
|
||||||
|
interface Spark {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
px: number // 上一帧位置(拖尾起点)
|
||||||
|
py: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
size: number
|
||||||
|
color: string
|
||||||
|
life: number
|
||||||
|
decay: number
|
||||||
|
gravity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 闪烁星光(四角星高光)
|
||||||
|
interface Sparkle {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
size: number
|
||||||
|
rotation: number
|
||||||
|
spin: number
|
||||||
|
color: string
|
||||||
|
life: number
|
||||||
|
decay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中心爆闪
|
||||||
|
interface Flash {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
radius: number
|
||||||
|
maxRadius: number
|
||||||
|
color: string
|
||||||
|
life: number
|
||||||
|
decay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Shockwave {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
radius: number
|
||||||
|
maxRadius: number
|
||||||
|
color: string
|
||||||
|
width: number
|
||||||
|
life: number
|
||||||
|
decay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrushItemConfig {
|
||||||
|
colors: string[] // 碎片主体色
|
||||||
|
sparkColors: string[] // 火花/高光色(偏亮)
|
||||||
|
shapes: ParticleShape[]
|
||||||
|
debrisCount: number
|
||||||
|
sparkCount: number
|
||||||
|
sparkleCount: number
|
||||||
|
speedMin: number
|
||||||
|
speedMax: number
|
||||||
|
sizeMin: number
|
||||||
|
sizeMax: number
|
||||||
|
gravity: number
|
||||||
|
flashColor: string // 中心爆闪核心色
|
||||||
|
shockwaveColor: string
|
||||||
|
confetti?: boolean // balloon: 碎片改为彩纸飘落
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 物品差异化配置
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
|
||||||
|
egg: {
|
||||||
|
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFD54F', '#FFC107'],
|
||||||
|
sparkColors: ['#FFFFFF', '#FFF9C4', '#FFEE58'],
|
||||||
|
shapes: ['circle', 'circle', 'circle'],
|
||||||
|
debrisCount: 20,
|
||||||
|
sparkCount: 16,
|
||||||
|
sparkleCount: 5,
|
||||||
|
speedMin: 3,
|
||||||
|
speedMax: 6,
|
||||||
|
sizeMin: 4,
|
||||||
|
sizeMax: 10,
|
||||||
|
gravity: 0.34,
|
||||||
|
flashColor: '#FFFDE7',
|
||||||
|
shockwaveColor: 'rgba(255, 224, 130, 0.85)',
|
||||||
|
},
|
||||||
|
walnut: {
|
||||||
|
colors: ['#5D4037', '#795548', '#8D6E63', '#A1887F', '#3E2723'],
|
||||||
|
sparkColors: ['#FFCC80', '#FFB74D', '#FFE0B2'],
|
||||||
|
shapes: ['square', 'triangle', 'rect'],
|
||||||
|
debrisCount: 22,
|
||||||
|
sparkCount: 18,
|
||||||
|
sparkleCount: 5,
|
||||||
|
speedMin: 2.8,
|
||||||
|
speedMax: 5,
|
||||||
|
sizeMin: 4,
|
||||||
|
sizeMax: 9,
|
||||||
|
gravity: 0.42,
|
||||||
|
flashColor: '#FFE0B2',
|
||||||
|
shockwaveColor: 'rgba(255, 167, 38, 0.8)',
|
||||||
|
},
|
||||||
|
can: {
|
||||||
|
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1', '#607D8B'],
|
||||||
|
sparkColors: ['#FFFFFF', '#E1F5FE', '#B3E5FC'],
|
||||||
|
shapes: ['rect', 'rect', 'triangle', 'square'],
|
||||||
|
debrisCount: 24,
|
||||||
|
sparkCount: 20,
|
||||||
|
sparkleCount: 6,
|
||||||
|
speedMin: 4,
|
||||||
|
speedMax: 7,
|
||||||
|
sizeMin: 3,
|
||||||
|
sizeMax: 11,
|
||||||
|
gravity: 0.36,
|
||||||
|
flashColor: '#FFFFFF',
|
||||||
|
shockwaveColor: 'rgba(207, 216, 220, 0.9)',
|
||||||
|
},
|
||||||
|
balloon: {
|
||||||
|
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE', '#FF6E40'],
|
||||||
|
sparkColors: ['#FFFFFF', '#FF80AB', '#82B1FF', '#FFFF8D'],
|
||||||
|
shapes: ['rect', 'rect', 'square'],
|
||||||
|
debrisCount: 28,
|
||||||
|
sparkCount: 18,
|
||||||
|
sparkleCount: 8,
|
||||||
|
speedMin: 4,
|
||||||
|
speedMax: 7.5,
|
||||||
|
sizeMin: 4,
|
||||||
|
sizeMax: 8,
|
||||||
|
gravity: 0.14,
|
||||||
|
flashColor: '#FCE4EC',
|
||||||
|
shockwaveColor: 'rgba(255, 64, 129, 0.85)',
|
||||||
|
confetti: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHYSICS = {
|
||||||
|
AIR_RESISTANCE: 0.985,
|
||||||
|
SPARK_RESISTANCE: 0.92,
|
||||||
|
DEBRIS_DECAY: 0.018,
|
||||||
|
SPARK_DECAY: 0.035,
|
||||||
|
SPARKLE_DECAY: 0.045,
|
||||||
|
FLASH_DECAY: 0.11,
|
||||||
|
SHOCKWAVE_DECAY: 0.045,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const canvasNode = ref<any>(null)
|
||||||
|
const ctx = ref<any>(null)
|
||||||
|
const canvasWidth = ref<number>(240)
|
||||||
|
const canvasHeight = ref<number>(240)
|
||||||
|
const dpr = ref<number>(1)
|
||||||
|
|
||||||
|
const rafId = ref<number | null>(null)
|
||||||
|
const renderRunning = ref<boolean>(false)
|
||||||
|
|
||||||
|
const debrisList = ref<Debris[]>([])
|
||||||
|
const sparkList = ref<Spark[]>([])
|
||||||
|
const sparkleList = ref<Sparkle[]>([])
|
||||||
|
const flashList = ref<Flash[]>([])
|
||||||
|
const shockwaves = ref<Shockwave[]>([])
|
||||||
|
|
||||||
|
const instance = getCurrentInstance()
|
||||||
|
|
||||||
|
// 由父级 prop 信号驱动触发特效(避免跨组件 ref 调用, 小程序端更稳)
|
||||||
|
const props = defineProps<{
|
||||||
|
crushSignal?: { type: CrushItemType; nonce: number } | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.crushSignal,
|
||||||
|
(sig) => {
|
||||||
|
if (sig) triggerCrush(sig.type)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initCanvas()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 初始化 Canvas 2D
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function initCanvas() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!instance) return
|
||||||
|
|
||||||
|
const query = uni.createSelectorQuery().in(instance)
|
||||||
|
query.select('#crush')
|
||||||
|
.fields({ node: true, size: true } as any)
|
||||||
|
.exec((res: any[]) => {
|
||||||
|
if (!res || !res[0] || !res[0].node) {
|
||||||
|
// 降级:如果获取不到 node(非微信平台),不渲染
|
||||||
|
console.warn('[crush-canvas] canvas 2d not supported on this platform')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = res[0].node
|
||||||
|
const width = res[0].width
|
||||||
|
const height = res[0].height
|
||||||
|
|
||||||
|
// 获取设备像素比
|
||||||
|
const systemInfo = uni.getSystemInfoSync()
|
||||||
|
const pixelRatio = systemInfo.pixelRatio || 1
|
||||||
|
|
||||||
|
// 设置 canvas 物理像素尺寸(高清)
|
||||||
|
canvas.width = width * pixelRatio
|
||||||
|
canvas.height = height * pixelRatio
|
||||||
|
|
||||||
|
// 获取 2D 上下文
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
context.scale(pixelRatio, pixelRatio)
|
||||||
|
|
||||||
|
// 保存状态
|
||||||
|
canvasNode.value = canvas
|
||||||
|
ctx.value = context
|
||||||
|
canvasWidth.value = width
|
||||||
|
canvasHeight.value = height
|
||||||
|
dpr.value = pixelRatio
|
||||||
|
})
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 触发捏碎特效
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function triggerCrush(itemType: CrushItemType) {
|
||||||
|
if (!ctx.value || !canvasNode.value) return
|
||||||
|
|
||||||
|
const item = CRUSH_ITEMS[itemType]
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
const centerX = canvasWidth.value / 2
|
||||||
|
const centerY = canvasHeight.value / 2
|
||||||
|
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
||||||
|
|
||||||
|
// 1. 中心爆闪
|
||||||
|
flashList.value.push({
|
||||||
|
x: centerX,
|
||||||
|
y: centerY,
|
||||||
|
radius: maxR * 0.12,
|
||||||
|
maxRadius: maxR * 0.62,
|
||||||
|
color: item.flashColor,
|
||||||
|
life: 1.0,
|
||||||
|
decay: PHYSICS.FLASH_DECAY,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 双层冲击波
|
||||||
|
shockwaves.value.push({
|
||||||
|
x: centerX, y: centerY,
|
||||||
|
radius: 12, maxRadius: maxR * 0.62,
|
||||||
|
color: item.shockwaveColor, width: 5,
|
||||||
|
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
|
||||||
|
})
|
||||||
|
shockwaves.value.push({
|
||||||
|
x: centerX, y: centerY,
|
||||||
|
radius: 4, maxRadius: maxR * 0.42,
|
||||||
|
color: item.shockwaveColor, width: 3,
|
||||||
|
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 碎片(主体飞溅)
|
||||||
|
for (let i = 0; i < item.debrisCount; i++) {
|
||||||
|
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
|
||||||
|
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
|
||||||
|
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
|
||||||
|
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
|
||||||
|
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
|
||||||
|
|
||||||
|
// confetti(彩纸): 初速带强烈向上偏移 + 水平摇摆下落
|
||||||
|
const confetti = !!item.confetti
|
||||||
|
const vy0 = confetti
|
||||||
|
? Math.sin(angle) * speed - 2.5 // 先向上窜
|
||||||
|
: Math.sin(angle) * speed
|
||||||
|
|
||||||
|
debrisList.value.push({
|
||||||
|
x: centerX,
|
||||||
|
y: centerY,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: vy0,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
shape,
|
||||||
|
rotation: Math.random() * Math.PI * 2,
|
||||||
|
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
|
||||||
|
life: 1.0,
|
||||||
|
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
|
||||||
|
gravity: item.gravity,
|
||||||
|
glow: !confetti, // 彩纸不发光,实色碎片发光
|
||||||
|
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
|
||||||
|
age: Math.random() * Math.PI * 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 火花(细小高速亮点,带拖尾)
|
||||||
|
for (let i = 0; i < item.sparkCount; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2
|
||||||
|
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
|
||||||
|
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||||
|
sparkList.value.push({
|
||||||
|
x: centerX,
|
||||||
|
y: centerY,
|
||||||
|
px: centerX,
|
||||||
|
py: centerY,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
size: 1.5 + Math.random() * 2,
|
||||||
|
color,
|
||||||
|
life: 1.0,
|
||||||
|
decay: PHYSICS.SPARK_DECAY * (0.8 + Math.random() * 0.6),
|
||||||
|
gravity: item.gravity * 0.3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 闪烁星光
|
||||||
|
for (let i = 0; i < item.sparkleCount; i++) {
|
||||||
|
const r = maxR * (0.08 + Math.random() * 0.32)
|
||||||
|
const a = Math.random() * Math.PI * 2
|
||||||
|
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||||
|
sparkleList.value.push({
|
||||||
|
x: centerX + Math.cos(a) * r,
|
||||||
|
y: centerY + Math.sin(a) * r,
|
||||||
|
size: 10 + Math.random() * 16,
|
||||||
|
rotation: Math.random() * Math.PI,
|
||||||
|
spin: (Math.random() - 0.5) * 0.16,
|
||||||
|
color,
|
||||||
|
life: 1.0 + Math.random() * 0.4, // 错峰出现
|
||||||
|
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureRenderLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 渲染循环
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function ensureRenderLoop() {
|
||||||
|
if (renderRunning.value) return
|
||||||
|
renderRunning.value = true
|
||||||
|
renderLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoop() {
|
||||||
|
if (!canvasNode.value || !ctx.value) {
|
||||||
|
renderRunning.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = canvasWidth.value
|
||||||
|
const h = canvasHeight.value
|
||||||
|
|
||||||
|
// 碎片
|
||||||
|
for (let i = debrisList.value.length - 1; i >= 0; i--) {
|
||||||
|
const p = debrisList.value[i]
|
||||||
|
p.vy += p.gravity
|
||||||
|
p.vx *= PHYSICS.AIR_RESISTANCE
|
||||||
|
p.vy *= PHYSICS.AIR_RESISTANCE
|
||||||
|
p.x += p.vx
|
||||||
|
p.y += p.vy
|
||||||
|
p.age += 0.2
|
||||||
|
if (p.swing > 0) p.x += Math.sin(p.age) * p.swing // 彩纸摇摆
|
||||||
|
p.rotation += p.rotationSpeed
|
||||||
|
p.life -= p.decay
|
||||||
|
if (p.life <= 0 || p.x < -60 || p.x > w + 60 || p.y > h + 60) {
|
||||||
|
debrisList.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 火花
|
||||||
|
for (let i = sparkList.value.length - 1; i >= 0; i--) {
|
||||||
|
const s = sparkList.value[i]
|
||||||
|
s.px = s.x
|
||||||
|
s.py = s.y
|
||||||
|
s.vy += s.gravity
|
||||||
|
s.vx *= PHYSICS.SPARK_RESISTANCE
|
||||||
|
s.vy *= PHYSICS.SPARK_RESISTANCE
|
||||||
|
s.x += s.vx
|
||||||
|
s.y += s.vy
|
||||||
|
s.life -= s.decay
|
||||||
|
if (s.life <= 0) sparkList.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 闪烁星光
|
||||||
|
for (let i = sparkleList.value.length - 1; i >= 0; i--) {
|
||||||
|
const sp = sparkleList.value[i]
|
||||||
|
sp.rotation += sp.spin
|
||||||
|
sp.life -= sp.decay
|
||||||
|
if (sp.life <= 0) sparkleList.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中心爆闪
|
||||||
|
for (let i = flashList.value.length - 1; i >= 0; i--) {
|
||||||
|
const f = flashList.value[i]
|
||||||
|
f.radius += (f.maxRadius - f.radius) * 0.35
|
||||||
|
f.life -= f.decay
|
||||||
|
if (f.life <= 0) flashList.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冲击波
|
||||||
|
for (let i = shockwaves.value.length - 1; i >= 0; i--) {
|
||||||
|
const sw = shockwaves.value[i]
|
||||||
|
sw.radius += (sw.maxRadius - sw.radius) * 0.16
|
||||||
|
sw.life -= sw.decay
|
||||||
|
if (sw.life <= 0) shockwaves.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
draw()
|
||||||
|
|
||||||
|
const alive =
|
||||||
|
debrisList.value.length > 0 ||
|
||||||
|
sparkList.value.length > 0 ||
|
||||||
|
sparkleList.value.length > 0 ||
|
||||||
|
flashList.value.length > 0 ||
|
||||||
|
shockwaves.value.length > 0
|
||||||
|
|
||||||
|
if (alive) {
|
||||||
|
rafId.value = canvasNode.value.requestAnimationFrame(renderLoop) as unknown as number
|
||||||
|
} else {
|
||||||
|
rafId.value = null
|
||||||
|
renderRunning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 绘制
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const c = ctx.value
|
||||||
|
const w = canvasWidth.value
|
||||||
|
const h = canvasHeight.value
|
||||||
|
|
||||||
|
c.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
// —— 1. 冲击波(底层) ——
|
||||||
|
c.globalCompositeOperation = 'source-over'
|
||||||
|
shockwaves.value.forEach(sw => {
|
||||||
|
c.beginPath()
|
||||||
|
c.arc(sw.x, sw.y, sw.radius, 0, Math.PI * 2)
|
||||||
|
c.strokeStyle = applyAlphaToRgba(sw.color, Math.min(1, sw.life))
|
||||||
|
c.lineWidth = sw.width * sw.life
|
||||||
|
c.stroke()
|
||||||
|
})
|
||||||
|
|
||||||
|
// —— 2. 中心爆闪(叠加发光) ——
|
||||||
|
c.globalCompositeOperation = 'lighter'
|
||||||
|
flashList.value.forEach(f => {
|
||||||
|
const grd = c.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.radius)
|
||||||
|
const a = Math.min(1, f.life)
|
||||||
|
grd.addColorStop(0, hexToRgba(f.color, 0.95 * a))
|
||||||
|
grd.addColorStop(0.4, hexToRgba(f.color, 0.5 * a))
|
||||||
|
grd.addColorStop(1, hexToRgba(f.color, 0))
|
||||||
|
c.fillStyle = grd
|
||||||
|
c.beginPath()
|
||||||
|
c.arc(f.x, f.y, f.radius, 0, Math.PI * 2)
|
||||||
|
c.fill()
|
||||||
|
})
|
||||||
|
|
||||||
|
// —— 3. 碎片(实体,带辉光) ——
|
||||||
|
c.globalCompositeOperation = 'source-over'
|
||||||
|
debrisList.value.forEach(p => {
|
||||||
|
const alpha = Math.min(1, p.life)
|
||||||
|
const currentSize = p.size * (0.65 + p.life * 0.35)
|
||||||
|
|
||||||
|
// 辉光: 同色低透明大圆衬底
|
||||||
|
if (p.glow) {
|
||||||
|
c.fillStyle = hexToRgba(p.color, alpha * 0.22)
|
||||||
|
c.beginPath()
|
||||||
|
c.arc(p.x, p.y, currentSize * 1.9, 0, Math.PI * 2)
|
||||||
|
c.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.fillStyle = hexToRgba(p.color, alpha)
|
||||||
|
drawDebrisShape(c, p, currentSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
// —— 4. 火花(拖尾 + 亮点,叠加发光) ——
|
||||||
|
c.globalCompositeOperation = 'lighter'
|
||||||
|
sparkList.value.forEach(s => {
|
||||||
|
const a = Math.min(1, s.life)
|
||||||
|
// 拖尾线
|
||||||
|
c.strokeStyle = hexToRgba(s.color, a * 0.8)
|
||||||
|
c.lineWidth = s.size * a
|
||||||
|
c.lineCap = 'round'
|
||||||
|
c.beginPath()
|
||||||
|
c.moveTo(s.px, s.py)
|
||||||
|
c.lineTo(s.x, s.y)
|
||||||
|
c.stroke()
|
||||||
|
// 头部亮点
|
||||||
|
c.fillStyle = hexToRgba(s.color, a)
|
||||||
|
c.beginPath()
|
||||||
|
c.arc(s.x, s.y, s.size * a, 0, Math.PI * 2)
|
||||||
|
c.fill()
|
||||||
|
})
|
||||||
|
|
||||||
|
// —— 5. 闪烁星光(四角星,叠加发光) ——
|
||||||
|
sparkleList.value.forEach(sp => {
|
||||||
|
// life 在 1→0, 用 sin 做"先亮后灭"的缩放
|
||||||
|
const t = Math.max(0, Math.min(1, sp.life))
|
||||||
|
const scale = Math.sin(Math.min(1, sp.life) * Math.PI) // 0→1→0
|
||||||
|
if (scale <= 0.02) return
|
||||||
|
const a = t
|
||||||
|
drawStar(c, sp.x, sp.y, sp.size * scale, sp.size * scale * 0.32, sp.rotation, hexToRgba(sp.color, a))
|
||||||
|
})
|
||||||
|
|
||||||
|
c.globalCompositeOperation = 'source-over'
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawDebrisShape(c: any, p: Debris, size: number) {
|
||||||
|
switch (p.shape) {
|
||||||
|
case 'circle':
|
||||||
|
c.beginPath()
|
||||||
|
c.arc(p.x, p.y, size, 0, Math.PI * 2)
|
||||||
|
c.fill()
|
||||||
|
break
|
||||||
|
case 'square':
|
||||||
|
c.save()
|
||||||
|
c.translate(p.x, p.y)
|
||||||
|
c.rotate(p.rotation)
|
||||||
|
c.fillRect(-size, -size, size * 2, size * 2)
|
||||||
|
c.restore()
|
||||||
|
break
|
||||||
|
case 'rect':
|
||||||
|
c.save()
|
||||||
|
c.translate(p.x, p.y)
|
||||||
|
c.rotate(p.rotation)
|
||||||
|
c.fillRect(-size * 1.6, -size * 0.5, size * 3.2, size)
|
||||||
|
c.restore()
|
||||||
|
break
|
||||||
|
case 'triangle':
|
||||||
|
c.save()
|
||||||
|
c.translate(p.x, p.y)
|
||||||
|
c.rotate(p.rotation)
|
||||||
|
c.beginPath()
|
||||||
|
c.moveTo(0, -size)
|
||||||
|
c.lineTo(size, size)
|
||||||
|
c.lineTo(-size, size)
|
||||||
|
c.closePath()
|
||||||
|
c.fill()
|
||||||
|
c.restore()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 四角星(尖锐十字星),用于高光闪烁
|
||||||
|
function drawStar(c: any, cx: number, cy: number, outerR: number, innerR: number, rotation: number, fill: string) {
|
||||||
|
const points = 4
|
||||||
|
c.save()
|
||||||
|
c.translate(cx, cy)
|
||||||
|
c.rotate(rotation)
|
||||||
|
c.beginPath()
|
||||||
|
for (let i = 0; i < points * 2; i++) {
|
||||||
|
const r = i % 2 === 0 ? outerR : innerR
|
||||||
|
const a = (Math.PI * i) / points
|
||||||
|
const x = Math.cos(a) * r
|
||||||
|
const y = Math.sin(a) * r
|
||||||
|
if (i === 0) c.moveTo(x, y)
|
||||||
|
else c.lineTo(x, y)
|
||||||
|
}
|
||||||
|
c.closePath()
|
||||||
|
c.fillStyle = fill
|
||||||
|
c.fill()
|
||||||
|
c.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工具函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAlphaToRgba(rgba: string, alphaMultiplier: number): string {
|
||||||
|
const match = rgba.match(/rgba?\(([^)]+)\)/)
|
||||||
|
if (!match) return rgba
|
||||||
|
|
||||||
|
const parts = match[1].split(',').map(s => s.trim())
|
||||||
|
const r = parts[0]
|
||||||
|
const g = parts[1]
|
||||||
|
const b = parts[2]
|
||||||
|
const a = parts[3] ? parseFloat(parts[3]) : 1
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${a * alphaMultiplier})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 清理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (rafId.value !== null && canvasNode.value) {
|
||||||
|
try {
|
||||||
|
canvasNode.value.cancelAnimationFrame(rafId.value)
|
||||||
|
} catch (_) {}
|
||||||
|
rafId.value = null
|
||||||
|
}
|
||||||
|
renderRunning.value = false
|
||||||
|
debrisList.value = []
|
||||||
|
sparkList.value = []
|
||||||
|
sparkleList.value = []
|
||||||
|
flashList.value = []
|
||||||
|
shockwaves.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.crush-canvas-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crush-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<template>
|
||||||
|
<view class="walker-canvas-container">
|
||||||
|
<canvas
|
||||||
|
canvas-id="walker"
|
||||||
|
id="walker"
|
||||||
|
class="walker-canvas"
|
||||||
|
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
@touchcancel="onTouchCancel"
|
||||||
|
></canvas>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
isPlaying: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
bpm: {
|
||||||
|
type: Number,
|
||||||
|
default: 110
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canvasWidth: 320,
|
||||||
|
canvasHeight: 400,
|
||||||
|
ctx: null,
|
||||||
|
timer: null,
|
||||||
|
renderRunning: false,
|
||||||
|
|
||||||
|
// Interaction State (Spring Physics)
|
||||||
|
coreScale: 1,
|
||||||
|
coreTargetScale: 1,
|
||||||
|
coreVelocity: 0,
|
||||||
|
pressed: false,
|
||||||
|
|
||||||
|
// Animation Physics (Time)
|
||||||
|
time: 0,
|
||||||
|
lastFrameTime: 0,
|
||||||
|
|
||||||
|
// Metronome State
|
||||||
|
lastBeatTime: 0,
|
||||||
|
|
||||||
|
// Energy Waves
|
||||||
|
waveAmplitudeMultiplier: 1,
|
||||||
|
|
||||||
|
// Shockwaves
|
||||||
|
shockwaves: [],
|
||||||
|
|
||||||
|
// Rotation for outer ring
|
||||||
|
ringAngle: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initCanvas();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
this.renderRunning = false;
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isPlaying(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.waveAmplitudeMultiplier = 1;
|
||||||
|
this.ensureRenderLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initCanvas() {
|
||||||
|
// Delay to ensure Flexbox layout is fully complete before measuring
|
||||||
|
setTimeout(() => {
|
||||||
|
const query = uni.createSelectorQuery().in(this);
|
||||||
|
query.select('.walker-canvas-container').boundingClientRect(data => {
|
||||||
|
if (data && data.width > 0) {
|
||||||
|
this.canvasWidth = data.width;
|
||||||
|
this.canvasHeight = data.height > 100 ? data.height : data.width * 1.2;
|
||||||
|
} else {
|
||||||
|
const sys = uni.getSystemInfoSync();
|
||||||
|
this.canvasWidth = sys.windowWidth - 30; // approximate padding
|
||||||
|
this.canvasHeight = this.canvasWidth * 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ctx) {
|
||||||
|
this.ctx = uni.createCanvasContext('walker', this);
|
||||||
|
this.lastFrameTime = Date.now();
|
||||||
|
this.ensureRenderLoop();
|
||||||
|
}
|
||||||
|
}).exec();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Expose a method to be called from parent when a beat hits
|
||||||
|
triggerBeat() {
|
||||||
|
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||||
|
// Add a shockwave
|
||||||
|
this.shockwaves.push({
|
||||||
|
radius: coreRadius,
|
||||||
|
maxRadius: coreRadius * 2.2,
|
||||||
|
opacity: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spike the wave amplitude
|
||||||
|
this.waveAmplitudeMultiplier = 2.5;
|
||||||
|
|
||||||
|
// Slight pulse to the core
|
||||||
|
this.coreVelocity -= 0.05;
|
||||||
|
|
||||||
|
this.ensureRenderLoop();
|
||||||
|
},
|
||||||
|
|
||||||
|
onTouchStart(e) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
// Center
|
||||||
|
const cx = this.canvasWidth / 2;
|
||||||
|
const cy = this.canvasHeight / 2 - 30;
|
||||||
|
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||||
|
|
||||||
|
const dx = touch.x - cx;
|
||||||
|
const dy = touch.y - cy;
|
||||||
|
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
|
||||||
|
// Hit box slightly larger than the visual circle
|
||||||
|
if (dist <= coreRadius + 40) {
|
||||||
|
this.pressed = true;
|
||||||
|
this.coreTargetScale = 0.9;
|
||||||
|
this.ensureRenderLoop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (!this.pressed) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
const cx = this.canvasWidth / 2;
|
||||||
|
const cy = this.canvasHeight / 2 - 30;
|
||||||
|
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
|
||||||
|
|
||||||
|
const dx = touch.x - cx;
|
||||||
|
const dy = touch.y - cy;
|
||||||
|
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
|
||||||
|
// Slid out of the hit box - cancel the press
|
||||||
|
if (dist > coreRadius + 40) {
|
||||||
|
this.pressed = false;
|
||||||
|
this.coreTargetScale = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTouchEnd() {
|
||||||
|
// Unconditionally reset the visual scale
|
||||||
|
this.coreTargetScale = 1;
|
||||||
|
// Only emit toggle when the press has not been cancelled by drag-out
|
||||||
|
if (this.pressed) {
|
||||||
|
this.$emit('toggle-play');
|
||||||
|
}
|
||||||
|
this.pressed = false;
|
||||||
|
},
|
||||||
|
onTouchCancel() {
|
||||||
|
// System interruption (incoming call, notification pull-down, etc.):
|
||||||
|
// reset visuals only — do NOT count this as a user toggle intent.
|
||||||
|
this.coreTargetScale = 1;
|
||||||
|
this.pressed = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureRenderLoop() {
|
||||||
|
if (this.renderRunning) return;
|
||||||
|
this.renderRunning = true;
|
||||||
|
this.renderLoop();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderLoop() {
|
||||||
|
// Use a fixed time delta to prevent rubber-banding/jitter on unstable JS timers
|
||||||
|
const fixedDt = 0.016;
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.time += fixedDt;
|
||||||
|
} else {
|
||||||
|
this.time += fixedDt * 0.2; // slow drift when paused
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spring Physics for Core Scale
|
||||||
|
const tension = 120;
|
||||||
|
const friction = 12;
|
||||||
|
const force = (this.coreTargetScale - this.coreScale) * tension;
|
||||||
|
this.coreVelocity += force * fixedDt;
|
||||||
|
this.coreVelocity *= Math.exp(-friction * fixedDt);
|
||||||
|
this.coreScale += this.coreVelocity * fixedDt;
|
||||||
|
|
||||||
|
// Decay wave amplitude back to 1
|
||||||
|
if (this.waveAmplitudeMultiplier > 1) {
|
||||||
|
this.waveAmplitudeMultiplier -= fixedDt * 3;
|
||||||
|
if (this.waveAmplitudeMultiplier < 1) this.waveAmplitudeMultiplier = 1;
|
||||||
|
} else if (!this.isPlaying && this.waveAmplitudeMultiplier > 0.1) {
|
||||||
|
this.waveAmplitudeMultiplier -= fixedDt * 2;
|
||||||
|
if (this.waveAmplitudeMultiplier < 0.1) this.waveAmplitudeMultiplier = 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.draw();
|
||||||
|
|
||||||
|
this.ctx.draw(false);
|
||||||
|
|
||||||
|
// Decide whether to continue the render loop. Short-circuit on isPlaying first for perf.
|
||||||
|
const shouldContinue =
|
||||||
|
this.isPlaying ||
|
||||||
|
this.shockwaves.length > 0 ||
|
||||||
|
Math.abs(this.coreScale - this.coreTargetScale) >= 0.001 ||
|
||||||
|
Math.abs(this.coreVelocity) >= 0.001 ||
|
||||||
|
this.waveAmplitudeMultiplier > 0.11;
|
||||||
|
|
||||||
|
if (shouldContinue) {
|
||||||
|
// Polyfill requestAnimationFrame for uni-app
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.renderLoop();
|
||||||
|
}, 1000 / 60);
|
||||||
|
} else {
|
||||||
|
this.timer = null;
|
||||||
|
this.renderRunning = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const W = this.canvasWidth;
|
||||||
|
const H = this.canvasHeight;
|
||||||
|
|
||||||
|
// Clear entire canvas to ensure it is completely transparent and blends with the page background
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Dynamic majestic sizing to fill the huge screen area
|
||||||
|
const coreRadius = Math.min(W, H) * 0.32; // Make the central circle MASSIVE
|
||||||
|
const ringRadius = coreRadius + 18;
|
||||||
|
const cx = W / 2;
|
||||||
|
const cy = H / 2 - 30; // Slightly above center
|
||||||
|
|
||||||
|
// 3. Draw Shockwaves (Ripples)
|
||||||
|
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
|
||||||
|
const sw = this.shockwaves[i];
|
||||||
|
sw.radius += (sw.maxRadius - sw.radius) * 0.08;
|
||||||
|
sw.opacity -= 0.03;
|
||||||
|
|
||||||
|
if (sw.opacity <= 0) {
|
||||||
|
this.shockwaves.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, sw.radius, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(16, 185, 129, ${sw.opacity * 0.5})`;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Energy Waves (Bottom area)
|
||||||
|
this.drawWaves(ctx, W, H);
|
||||||
|
|
||||||
|
// 5. Draw Interactive BPM Core
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.scale(this.coreScale, this.coreScale);
|
||||||
|
|
||||||
|
// The Core Button (Emerald Gradient)
|
||||||
|
ctx.setShadow(0, 12, 24, 'rgba(16, 185, 129, 0.3)');
|
||||||
|
|
||||||
|
const coreGrad = ctx.createLinearGradient(-coreRadius, -coreRadius, coreRadius, coreRadius);
|
||||||
|
coreGrad.addColorStop(0, '#34d399'); // Light Mint
|
||||||
|
coreGrad.addColorStop(1, '#047857'); // Deep Emerald
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = coreGrad;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Clear shadow for internal elements
|
||||||
|
ctx.setShadow(0, 0, 0, 'transparent');
|
||||||
|
|
||||||
|
// Inner Subtle Highlight (Glass edge)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, coreRadius - 2, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Text - BPM Number (MASSIVE font)
|
||||||
|
const fontSize = Math.floor(coreRadius * 0.7); // Dynamic font size based on radius
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = `bold ${fontSize}px "DIN Condensed", "Inter", sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
// Removed shadowBlur for text to save low-end GPU performance
|
||||||
|
ctx.fillText(this.bpm.toString(), 0, -12);
|
||||||
|
|
||||||
|
// Text - 'BPM' Label
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
||||||
|
ctx.font = '500 16px "Inter", sans-serif';
|
||||||
|
ctx.fillText('BPM', 0, coreRadius * 0.3);
|
||||||
|
|
||||||
|
// Play/Pause Icon
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
const iconY = coreRadius * 0.55;
|
||||||
|
if (this.isPlaying) {
|
||||||
|
// Pause Icon (Two vertical bars)
|
||||||
|
ctx.fillRect(-8, iconY - 6, 5, 14);
|
||||||
|
ctx.fillRect(3, iconY - 6, 5, 14);
|
||||||
|
} else {
|
||||||
|
// Play Icon (Triangle)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-5, iconY - 8);
|
||||||
|
ctx.lineTo(9, iconY);
|
||||||
|
ctx.lineTo(-5, iconY + 8);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
},
|
||||||
|
|
||||||
|
drawWaves(ctx, W, H) {
|
||||||
|
const baseY = H * 0.82;
|
||||||
|
const dynamicAmp = H * 0.08;
|
||||||
|
// Reduced to 2 layers to save GPU/Bridge rendering time and ensure 60fps
|
||||||
|
const waves = [
|
||||||
|
{ color: 'rgba(52, 211, 153, 0.3)', speed: 1.5, freq: 0.012, amp: dynamicAmp * 0.7, offset: 0 },
|
||||||
|
{ color: 'rgba(16, 185, 129, 0.6)', speed: 2.5, freq: 0.015, amp: dynamicAmp, offset: Math.PI }
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeAmp = this.waveAmplitudeMultiplier;
|
||||||
|
const t = this.time;
|
||||||
|
|
||||||
|
waves.forEach(wave => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, H);
|
||||||
|
ctx.lineTo(0, baseY);
|
||||||
|
|
||||||
|
// Increased step size to drastically reduce JS-to-Native bridge commands
|
||||||
|
const step = 25;
|
||||||
|
for (let x = 0; x <= W; x += step) {
|
||||||
|
// Removed edge envelope taper so waves crash cleanly into the edge of the screen
|
||||||
|
const y = baseY + Math.sin(x * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add one final line to exactly W to ensure flush edge
|
||||||
|
const finalY = baseY + Math.sin(W * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
|
||||||
|
ctx.lineTo(W, finalY);
|
||||||
|
|
||||||
|
ctx.lineTo(W, H);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = wave.color;
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.walker-canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walker-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent; /* Pure transparent to blend into page */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="rootStyle">
|
||||||
|
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
|
||||||
|
<view class="stage-canvas-wrapper">
|
||||||
|
<WalkerCanvas
|
||||||
|
ref="walkerCanvasRef"
|
||||||
|
:bpm="currentBpm"
|
||||||
|
:is-playing="isPlaying"
|
||||||
|
@toggle-play="onCenterTap"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 音色选择 ===== -->
|
||||||
|
<view class="sound-selector">
|
||||||
|
<view
|
||||||
|
class="sound-option"
|
||||||
|
:class="{ active: currentSound === 'crisp' }"
|
||||||
|
@click="onSoundSelect('crisp')"
|
||||||
|
>
|
||||||
|
<text class="sound-icon">✨</text>
|
||||||
|
<text class="sound-label">清脆</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="sound-option"
|
||||||
|
:class="{ active: currentSound === 'wood' }"
|
||||||
|
@click="onSoundSelect('wood')"
|
||||||
|
>
|
||||||
|
<text class="sound-icon">🪵</text>
|
||||||
|
<text class="sound-label">木鱼</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||||
|
<view class="presets">
|
||||||
|
<view
|
||||||
|
v-for="p in LOOP_PRESETS"
|
||||||
|
:key="p.id"
|
||||||
|
class="preset"
|
||||||
|
:class="{ active: currentLoop === p.id }"
|
||||||
|
@click="onPresetTap(p.id)"
|
||||||
|
>
|
||||||
|
<text class="preset-name">{{ p.label }}</text>
|
||||||
|
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||||
|
<text class="preset-desc">{{ p.desc }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
|
||||||
|
<view class="custom-section">
|
||||||
|
<!-- 标题栏(始终显示) -->
|
||||||
|
<view class="custom-header" @click="onToggleCustom">
|
||||||
|
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 折叠内容(仅展开时显示) -->
|
||||||
|
<view v-if="customExpanded" class="custom-content">
|
||||||
|
<!-- BPM 微调 -->
|
||||||
|
<view class="row">
|
||||||
|
<text class="row-label">BPM</text>
|
||||||
|
<view class="bpm-stepper">
|
||||||
|
<view class="step-btn" @click="onBpmDelta(-5)">−5</view>
|
||||||
|
<view class="step-btn" @click="onBpmDelta(-1)">−1</view>
|
||||||
|
<view class="step-val">{{ currentBpm }}</view>
|
||||||
|
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
|
||||||
|
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 拍号 -->
|
||||||
|
<view class="row">
|
||||||
|
<text class="row-label">拍号</text>
|
||||||
|
<view class="meter-tabs">
|
||||||
|
<view
|
||||||
|
v-for="n in [2, 3, 4]"
|
||||||
|
:key="n"
|
||||||
|
class="meter-tab"
|
||||||
|
:class="{ active: customAccent === n }"
|
||||||
|
@click="onAccentSelect(n)"
|
||||||
|
>{{ n }}/4</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||||
|
import { useMetronome } from '../hooks/useMetronome'
|
||||||
|
import WalkerCanvas from './components/walker-canvas.vue'
|
||||||
|
|
||||||
|
type LoopId = 'slow' | 'normal' | 'brisk'
|
||||||
|
interface LoopPreset {
|
||||||
|
id: LoopId
|
||||||
|
bpm: number
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||||
|
{ id: 'slow', bpm: 110, label: '慢走', desc: '热身 · 恢复' },
|
||||||
|
{ id: 'normal', bpm: 130, label: '健走', desc: '日常 · 通勤' },
|
||||||
|
{ id: 'brisk', bpm: 150, label: '快走', desc: '提速 · 燃脂' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const customExpanded = ref<boolean>(false)
|
||||||
|
const currentLoop = ref<LoopId | null>('normal')
|
||||||
|
const currentSound = ref<'crisp' | 'wood'>('crisp')
|
||||||
|
const walkerCanvasRef = ref<any>(null)
|
||||||
|
|
||||||
|
/* 节拍器音效配置 */
|
||||||
|
const SOUND_PRESETS = {
|
||||||
|
crisp: {
|
||||||
|
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539371ebe13672.mp3',
|
||||||
|
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539376ff628472.mp3',
|
||||||
|
},
|
||||||
|
wood: {
|
||||||
|
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
|
||||||
|
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一采用高精度引擎,彻底抛弃卡顿的后台播放引擎
|
||||||
|
const fg = useMetronome({
|
||||||
|
initialBpm: 130,
|
||||||
|
accentEvery: 2,
|
||||||
|
clickSrc: SOUND_PRESETS.crisp.normal,
|
||||||
|
accentSrc: SOUND_PRESETS.crisp.accent,
|
||||||
|
poolSize: 4,
|
||||||
|
// 【完美音画同步核心】
|
||||||
|
onBeat: (index, isAccent) => {
|
||||||
|
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
|
||||||
|
walkerCanvasRef.value.triggerBeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPlaying = computed(() => fg.isPlaying.value)
|
||||||
|
const currentBpm = computed(() => fg.bpm.value)
|
||||||
|
const customAccent = computed(() => fg.accentEvery.value)
|
||||||
|
const intervalMs = computed(() => 60000 / fg.bpm.value)
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 中央圆按钮
|
||||||
|
* ============================================================ */
|
||||||
|
const onCenterTap = () => {
|
||||||
|
if (fg.isPlaying.value) {
|
||||||
|
fg.stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
} else {
|
||||||
|
fg.start()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 档位卡片
|
||||||
|
* ============================================================ */
|
||||||
|
const onPresetTap = (id: LoopId) => {
|
||||||
|
const preset = LOOP_PRESETS.find(p => p.id === id)
|
||||||
|
if (!preset) return
|
||||||
|
|
||||||
|
currentLoop.value = id
|
||||||
|
fg.setBpm(preset.bpm)
|
||||||
|
if (!fg.isPlaying.value) {
|
||||||
|
fg.start()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 自定义折叠区切换
|
||||||
|
* ============================================================ */
|
||||||
|
const onToggleCustom = () => {
|
||||||
|
customExpanded.value = !customExpanded.value
|
||||||
|
if (customExpanded.value) {
|
||||||
|
fg.preload()
|
||||||
|
} else {
|
||||||
|
// 收起时,如果当前 BPM 刚好匹配某个档位,高亮对应档位卡片
|
||||||
|
const matched = LOOP_PRESETS.find(p => p.bpm === fg.bpm.value)
|
||||||
|
currentLoop.value = matched ? matched.id : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBpmDelta = (delta: number) => {
|
||||||
|
fg.setBpm(fg.bpm.value + delta)
|
||||||
|
currentLoop.value = null // 用户手动微调后取消档位高亮
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAccentSelect = (n: number) => {
|
||||||
|
fg.setAccentEvery(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSoundSelect = (sound: 'crisp' | 'wood') => {
|
||||||
|
currentSound.value = sound
|
||||||
|
const preset = SOUND_PRESETS[sound]
|
||||||
|
fg.updateAudioSrc(preset.normal, preset.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootStyle = computed(() => ({
|
||||||
|
'--beat-duration': `${intervalMs.value}ms`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
fg.preload()
|
||||||
|
})
|
||||||
|
|
||||||
|
function cleanupMetronome() {
|
||||||
|
fg.stop()
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
onHide(cleanupMetronome)
|
||||||
|
onUnload(cleanupMetronome)
|
||||||
|
|
||||||
|
onUnmounted(cleanupMetronome)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* ============================================================
|
||||||
|
* 设计 token —— 浅色清爽
|
||||||
|
* ============================================================ */
|
||||||
|
$bg-color: #f8fafc;
|
||||||
|
$card-bg: #ffffff;
|
||||||
|
$card-border: rgba(15, 23, 42, 0.06);
|
||||||
|
$text-1: #0f172a;
|
||||||
|
$text-2: #475569;
|
||||||
|
$text-3: #94a3b8;
|
||||||
|
$brand: #10b981;
|
||||||
|
$brand-soft: #d1fae5;
|
||||||
|
$brand-deep: #047857;
|
||||||
|
$warn: #f59e0b;
|
||||||
|
$warn-soft: #fef3c7;
|
||||||
|
$radius-card: 24rpx;
|
||||||
|
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 页面容器
|
||||||
|
* ============================================================ */
|
||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 30rpx 28rpx 50rpx;
|
||||||
|
background: $bg-color;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
color: $text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 统一运动律动视区 (Canvas)
|
||||||
|
* ============================================================ */
|
||||||
|
.stage-canvas-wrapper {
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: -28rpx;
|
||||||
|
margin-right: -28rpx;
|
||||||
|
flex-shrink: 0; /* 防止被压缩 */
|
||||||
|
height: 600rpx; /* 固定高度,不随内容变化 */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 音色选择器
|
||||||
|
* ============================================================ */
|
||||||
|
.sound-selector {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sound-option {
|
||||||
|
flex: 1;
|
||||||
|
background: $card-bg;
|
||||||
|
border: 2rpx solid $card-border;
|
||||||
|
border-radius: $radius-card;
|
||||||
|
padding: 20rpx 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transition: all 0.18s;
|
||||||
|
|
||||||
|
.sound-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sound-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-2;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||||
|
border-color: $brand;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
|
||||||
|
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
|
||||||
|
|
||||||
|
.sound-label {
|
||||||
|
color: $brand-deep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 三档位推荐
|
||||||
|
* ============================================================ */
|
||||||
|
.presets {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset {
|
||||||
|
flex: 1;
|
||||||
|
background: $card-bg;
|
||||||
|
border: 2rpx solid $card-border;
|
||||||
|
border-radius: $radius-card;
|
||||||
|
padding: 26rpx 8rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transition: all 0.18s;
|
||||||
|
|
||||||
|
.preset-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-1;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
.preset-bpm {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-2;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.preset-desc {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $brand-soft;
|
||||||
|
border-color: $brand;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
|
||||||
|
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
|
||||||
|
|
||||||
|
.preset-name {
|
||||||
|
color: $brand-deep;
|
||||||
|
}
|
||||||
|
.preset-bpm {
|
||||||
|
color: $brand-deep;
|
||||||
|
}
|
||||||
|
.preset-desc {
|
||||||
|
color: $brand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 自定义节奏区域(标题栏 + 折叠内容)
|
||||||
|
* ============================================================ */
|
||||||
|
.custom-section {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-header {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16rpx 6rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.custom-title {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: $text-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-content {
|
||||||
|
width: 100%;
|
||||||
|
background: $card-bg;
|
||||||
|
border: 2rpx solid rgba(245, 158, 11, 0.25);
|
||||||
|
border-radius: $radius-card;
|
||||||
|
box-shadow: 0 6rpx 18rpx rgba(245, 158, 11, 0.08);
|
||||||
|
padding: 20rpx 24rpx 22rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
|
||||||
|
.warn-dot {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: $warn;
|
||||||
|
}
|
||||||
|
.warn-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #b45309;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $text-2;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 70rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bpm-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
min-width: 56rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-2;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #e2e8f0;
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.step-val {
|
||||||
|
min-width: 84rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $text-1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: $warn-soft;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
.meter-tab {
|
||||||
|
height: 52rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-2;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $warn;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 底部行(已废弃,保留样式以防引用)
|
||||||
|
* ============================================================ */
|
||||||
|
.bottom-row {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-link {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
padding: 8rpx 4rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: $text-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-hint {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $text-3;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# 训练模块静态资源
|
||||||
|
|
||||||
|
本目录用于存放"练一练"功能的所有音频素材。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
training/static/
|
||||||
|
├── audio/ # 当前为空 —— 节拍器音频已全部迁移到 COS CDN
|
||||||
|
├── voice/ # (规划中) TTS 语音教练
|
||||||
|
│ ├── numbers/ 数字报数 1~30
|
||||||
|
│ └── prompts/ 开始/休息/再来 等口令
|
||||||
|
├── bgm/ # (规划中) 训练/休息背景乐
|
||||||
|
└── footprint.svg # 脚印图标(早期方案残留,可保留作为备用素材)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 节拍器音频已完全走 CDN(`gz-1349751149.cos.ap-guangzhou.myqcloud.com`),减少小程序包体积约 210 KB。`useMetronomeBg.ts` / `useMetronome.ts` 中的 URL 即源信息,更换音色只需改 hook 里的常量。
|
||||||
|
|
||||||
|
## 准备步骤
|
||||||
|
|
||||||
|
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||||
|
|
||||||
|
需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key:
|
||||||
|
|
||||||
|
- 文档: https://platform.xiaomimimo.com/
|
||||||
|
- API Key 形如 `sk-xxxxxxxx`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MIMO_API_KEY=sk-your-key-here
|
||||||
|
|
||||||
|
cd uniapp
|
||||||
|
node scripts/generate-voice.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
|
||||||
|
|
||||||
|
**可选参数**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean)
|
||||||
|
node scripts/generate-voice.mjs --voice 茉莉
|
||||||
|
|
||||||
|
# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
|
||||||
|
node scripts/generate-voice.mjs --format wav
|
||||||
|
|
||||||
|
# 强制重新生成(默认存在则跳过)
|
||||||
|
node scripts/generate-voice.mjs --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**音色推荐**(中文女声更适合健身教练):
|
||||||
|
- `冰糖`:温柔甜美,亲和力强(默认)
|
||||||
|
- `茉莉`:清爽利落,有"运动博主"感
|
||||||
|
- `苏打`:年轻男声,有力量感
|
||||||
|
- `白桦`:成熟男声,沉稳
|
||||||
|
|
||||||
|
### 2. 节拍器 click / 循环音轨
|
||||||
|
|
||||||
|
**全部托管在腾讯云 COS(2026-05-26 上传)**,本地不再保留:
|
||||||
|
|
||||||
|
| 用途 | 文件 | 大小 | 引擎 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 单拍 click(基础) | `click.mp3` | 3.4 KB | InnerAudio |
|
||||||
|
| 单拍 click(木鱼,推荐) | `click-wood.mp3` | 4.6 KB | InnerAudio (custom 模式) |
|
||||||
|
| 循环音轨 慢走 80 BPM | `loop_80bpm_2.mp3` | 71 KB | BgAudio (preset) |
|
||||||
|
| 循环音轨 健走 110 BPM | `loop_110bpm_2.mp3` | 65 KB | BgAudio (preset) |
|
||||||
|
| 循环音轨 快走 130 BPM | `loop_130bpm_2.mp3` | 66 KB | BgAudio (preset) |
|
||||||
|
|
||||||
|
> 微信 `BackgroundAudioManager.src` 只接受 https URL,不支持包内资源,所以必须走 CDN。
|
||||||
|
> COS 域名已加入小程序后台 `downloadFile 合法域名`,跑步视频也走同一域名。
|
||||||
|
|
||||||
|
更换音色:去 [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 找新素材 → 上传到 COS → 在 `useMetronomeBg.ts` / `useMetronome.ts` 里替换 URL 即可。
|
||||||
|
|
||||||
|
### 3. 背景音乐
|
||||||
|
|
||||||
|
每首 30 秒~2 分钟即可(loop 后听不出接缝)。
|
||||||
|
|
||||||
|
推荐来源(免费可商用):
|
||||||
|
- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||||
|
- [freemusicarchive.org](https://freemusicarchive.org)
|
||||||
|
- [bensound.com](https://bensound.com)(含署名)
|
||||||
|
|
||||||
|
文件大小建议 < 1MB(mp3 128kbps 单声道即可)。
|
||||||
|
|
||||||
|
## 在代码里被引用的位置
|
||||||
|
|
||||||
|
- `training/hooks/useMetronome.ts` → `DEFAULT_CLICK_SRC` (CDN: click.mp3)
|
||||||
|
- `training/hooks/useMetronomeBg.ts` → `LOOP_PRESETS[*].src` (CDN: loop_*.mp3)
|
||||||
|
- `training/pages/metronome.vue` → `CLICK_WOOD_URL` (CDN: click-wood.mp3, custom 模式)
|
||||||
|
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3` (规划中)
|
||||||
|
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3` (规划中)
|
||||||
|
|
||||||
|
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
||||||
|
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
||||||
|
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后台/锁屏播放设计备忘(未来健身模块用)
|
||||||
|
|
||||||
|
> 节拍器目前用 `InnerAudioContext` + `requiredBackgroundModes:["audio"]` 已能覆盖 5~10 分钟健走场景。
|
||||||
|
> 真要做长时间训练 BGM、锁屏控制条等高级能力,参考下面的 `BackgroundAudioManager` 方案。
|
||||||
|
|
||||||
|
### 引擎选择对照表
|
||||||
|
|
||||||
|
健身模块的音频天然分两类,配两套引擎不冲突:
|
||||||
|
|
||||||
|
| 音频类型 | 时长 | 推荐引擎 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 训练/休息 BGM | 30s~2min 循环 | **BackgroundAudioManager** | 长流、需要后台/锁屏不停 |
|
||||||
|
| 语音教练("开始/休息/再来") | 1~3s 单句 | InnerAudioContext | 短促,前台用即可 |
|
||||||
|
| 报数(1, 2, 3...) | 0.5~1s | InnerAudioContext + 池子 | 高频短促 |
|
||||||
|
| 节拍器 click | 50~150ms | InnerAudioContext + 池子 | 极短,BgAudio 不接受 |
|
||||||
|
|
||||||
|
**关键约束:BgAudio 是全局单例,一次只能播 1 个音频**。所以让它专门播 BGM 这类"长流",其他短音用 InnerAudio 配合,互不打架。
|
||||||
|
|
||||||
|
### BackgroundAudioManager 限制清单(踩坑预警)
|
||||||
|
|
||||||
|
1. **全局单例**:整个小程序同一时刻只能播一个,多场景要协调切换
|
||||||
|
2. **音频时长 ≥ 1 秒**:太短的 click 会被微信判定异常忽略
|
||||||
|
3. **必填 metadata**:`title` / `coverImgUrl` / `singer` / `epname` / `webUrl` 缺一会报错
|
||||||
|
4. **切 src 有延迟**:200~500ms 初始化抖动,频繁切换会卡顿
|
||||||
|
5. **必须声明 `requiredBackgroundModes:["audio"]`** 才能后台播放
|
||||||
|
6. **iOS 锁屏豁免**:声明后能锁屏继续播,无 5 分钟时长限制(vs InnerAudio 的 5min)
|
||||||
|
7. **会显示系统控制条**:锁屏/通知栏出现带封面+暂停按钮的控制条
|
||||||
|
|
||||||
|
### 推荐架构(健身模块上线时)
|
||||||
|
|
||||||
|
```
|
||||||
|
training/hooks/
|
||||||
|
├── useTrainingBgm.ts → BackgroundAudioManager (后台/锁屏继续放音乐)
|
||||||
|
├── useVoiceCoach.ts → InnerAudioContext (语音指导,前台用)
|
||||||
|
└── useMetronome.ts → InnerAudioContext (节拍器,池子方案,保持现状)
|
||||||
|
```
|
||||||
|
|
||||||
|
页面 `pages.json` 声明:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "pages/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "练一练",
|
||||||
|
"requiredBackgroundModes": ["audio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
锁屏会显示 BGM 控制条,老人能直接在锁屏点暂停。训练页面通过 `bgm.onPause()` 监听同步暂停训练,体验连贯。
|
||||||
|
|
||||||
|
### 必备资产清单
|
||||||
|
|
||||||
|
到时候要准备的文件:
|
||||||
|
|
||||||
|
- **BGM 2 首**:`bgm/train-light.mp3`(训练)、`bgm/rest-meditation.mp3`(休息)
|
||||||
|
- 每个 30s~1min,128kbps 单声道,30~80KB
|
||||||
|
- 推荐来源:[pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||||
|
- **锁屏封面**:`audio/cover.jpg` 200×200
|
||||||
|
- 简洁绿色背景 + 训练 emoji 即可
|
||||||
|
|
||||||
|
### BackgroundAudioManager API 速查
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const bgm = uni.getBackgroundAudioManager()
|
||||||
|
|
||||||
|
/* 必填 metadata,缺一会报错 */
|
||||||
|
bgm.title = '健走训练中'
|
||||||
|
bgm.coverImgUrl = '/training/static/audio/cover.jpg'
|
||||||
|
bgm.epname = '甄养堂'
|
||||||
|
bgm.singer = '健走节拍'
|
||||||
|
bgm.webUrl = '' // 必填,空字符串可
|
||||||
|
|
||||||
|
/* src 一旦赋值会自动播放 */
|
||||||
|
bgm.src = '/training/static/audio/bgm/train-light.mp3'
|
||||||
|
|
||||||
|
/* 控制 */
|
||||||
|
bgm.pause() // 暂停 (锁屏控制条仍在)
|
||||||
|
bgm.play() // 继续
|
||||||
|
bgm.stop() // 真停 + 隐藏控制条
|
||||||
|
bgm.seek(30) // 跳到 30s
|
||||||
|
|
||||||
|
/* 事件监听 — 用户从锁屏点暂停时会触发 */
|
||||||
|
bgm.onPause(() => { /* 同步训练 UI 状态 */ })
|
||||||
|
bgm.onPlay(() => {})
|
||||||
|
bgm.onStop(() => {})
|
||||||
|
bgm.onEnded(() => { /* 不 loop 时触发,可手动接下一首 */ })
|
||||||
|
bgm.onError((err) => { console.error(err) })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 切换不同场景音乐的模式
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function switchBgm(scene: 'training' | 'resting' | 'none') {
|
||||||
|
if (scene === 'none') {
|
||||||
|
bgm.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bgm.title = scene === 'training' ? '健走训练中' : '休息恢复中'
|
||||||
|
bgm.src = scene === 'training'
|
||||||
|
? '/training/static/audio/bgm/train-light.mp3'
|
||||||
|
: '/training/static/audio/bgm/rest-meditation.mp3'
|
||||||
|
/* 注:setSrc 会自动播放,有 200~500ms 切换延迟 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 节拍器要不要也升级到 BgAudio?
|
||||||
|
|
||||||
|
**目前不需要**。节拍器升级 BgAudio 的代价:
|
||||||
|
- 需要预合成 3 个档位的循环 mp3(80/110/130 BPM × 2 拍)
|
||||||
|
- 必须砍掉右下角微调按钮(预合成 mp3 改不了 BPM)
|
||||||
|
- 切档位有 200~500ms 卡顿
|
||||||
|
|
||||||
|
如果未来发现"健走 30 分钟以上锁屏会停"才考虑改。当前 5~10 分钟场景 InnerAudio + `requiredBackgroundModes` 够用。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150">
|
||||||
|
<g fill="#e2e8f0" fill-opacity="0.92">
|
||||||
|
<path d="M 50,30 C 38,30 28,32 24,40 C 19,50 18,60 22,70 C 26,82 26,92 28,102 C 30,122 40,140 50,140 C 60,140 70,122 72,102 C 74,92 74,82 78,70 C 82,60 81,50 76,40 C 72,32 62,30 50,30 Z"/>
|
||||||
|
<ellipse cx="30" cy="20" rx="6.5" ry="8" transform="rotate(-18 30 20)"/>
|
||||||
|
<ellipse cx="43" cy="11" rx="5" ry="6.8" transform="rotate(-6 43 11)"/>
|
||||||
|
<ellipse cx="55" cy="9" rx="4.5" ry="6.2" transform="rotate(4 55 9)"/>
|
||||||
|
<ellipse cx="66" cy="13" rx="4" ry="5.5" transform="rotate(13 66 13)"/>
|
||||||
|
<ellipse cx="76" cy="20" rx="3.5" ry="5" transform="rotate(22 76 20)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,276 @@
|
|||||||
|
# 握力环训练音效采集清单
|
||||||
|
|
||||||
|
> 更新日期: 2026-05-28
|
||||||
|
> 用途: 为握力环训练页面添加完整音效体系
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 总体规划
|
||||||
|
|
||||||
|
| 类型 | 数量 | 单文件大小 | 总大小预算 | 优先级 |
|
||||||
|
|------|------|-----------|-----------|--------|
|
||||||
|
| 节拍音效 | 1-3 个音色 | 5-10 KB | < 30 KB | P0 |
|
||||||
|
| 捏碎音效 | 4 个 | 20-50 KB | < 200 KB | P0 |
|
||||||
|
| 背景音乐 | 1-2 首 | 200-500 KB | < 1 MB | P1 |
|
||||||
|
| 语音鼓励 | 4-8 句 | 10-30 KB | < 200 KB | P1 |
|
||||||
|
|
||||||
|
**总计预算**: < 1.5 MB(都放 CDN,不影响小程序包体积)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🥁 一、节拍音效 (P0 - 必需)
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
训练时按 BPM 节奏播放,引导用户握紧/松开。
|
||||||
|
|
||||||
|
### 推荐音色 (任选 1-3 个)
|
||||||
|
|
||||||
|
| 音色 | 推荐度 | 描述 | Pixabay 搜索词 |
|
||||||
|
|------|--------|------|---------------|
|
||||||
|
| 木鱼 | ⭐⭐⭐⭐⭐ | 温暖、东方意境,适合养生主题 | `wood block`, `wooden tap`, `muyu` |
|
||||||
|
| 心跳 | ⭐⭐⭐⭐ | 有运动感,代入感强 | `heart beat`, `heartbeat single` |
|
||||||
|
| 轻柔敲击 | ⭐⭐⭐⭐ | 不刺耳,适合长时间训练 | `soft tap`, `gentle knock` |
|
||||||
|
| 竹片声 | ⭐⭐⭐ | 清脆,有节奏感 | `bamboo tap`, `bamboo click` |
|
||||||
|
| 鼓点 | ⭐⭐⭐ | 有力量感,适合"重度"档位 | `kick drum soft`, `tom drum tap` |
|
||||||
|
|
||||||
|
### 技术要求
|
||||||
|
- **格式**: mp3
|
||||||
|
- **时长**: 50-150ms (不能太长,会重叠)
|
||||||
|
- **采样率**: 22050Hz 或 44100Hz
|
||||||
|
- **声道**: 单声道
|
||||||
|
- **大小**: < 10 KB
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
```
|
||||||
|
grip-tick-wood.mp3 # 木鱼
|
||||||
|
grip-tick-heart.mp3 # 心跳
|
||||||
|
grip-tick-soft.mp3 # 轻柔
|
||||||
|
grip-tick-bamboo.mp3 # 竹片
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💥 二、捏碎音效 (P0 - 必需)
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
达成奖励里程碑时播放,与粒子特效同步。
|
||||||
|
|
||||||
|
### 音效清单
|
||||||
|
|
||||||
|
| 奖励 | 触发次数 | Pixabay 搜索词 | 时长 | 备注 |
|
||||||
|
|------|---------|---------------|------|------|
|
||||||
|
| 🥚 鸡蛋 | 8-12 | `egg crack`, `eggshell break`, `crack sound` | 0.5-1s | 清脆短促 |
|
||||||
|
| 🥜 核桃 | 15-20 | `nut crack`, `walnut break`, `wood snap` | 0.5-1s | 木质爆裂感 |
|
||||||
|
| 🥫 易拉罐 | 25-30 | `can crush`, `aluminum crush`, `metal crunch` | 1-2s | 金属挤压 |
|
||||||
|
| 🎈 气球 | 40-50 | `balloon pop`, `pop burst`, `balloon explosion` | 0.3-0.5s | 短促爆裂 |
|
||||||
|
|
||||||
|
### 技术要求
|
||||||
|
- **格式**: mp3
|
||||||
|
- **时长**: 0.3-2s
|
||||||
|
- **大小**: < 50 KB
|
||||||
|
- **音量**: 适中(避免突然吓到用户)
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
```
|
||||||
|
crush-egg.mp3
|
||||||
|
crush-walnut.mp3
|
||||||
|
crush-can.mp3
|
||||||
|
crush-balloon.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 试听建议
|
||||||
|
下载前一定要试听,优先选择:
|
||||||
|
- ✅ 干净,无杂音
|
||||||
|
- ✅ 中等音量(峰值不爆音)
|
||||||
|
- ✅ 短促有力(不拖沓)
|
||||||
|
- ❌ 避免: 太长、太尖锐、有人声
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎵 三、背景音乐 BGM (P1 - 推荐)
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
训练时持续播放,营造轻松氛围,缓解握力训练的枯燥感。
|
||||||
|
|
||||||
|
### 风格推荐
|
||||||
|
|
||||||
|
| 风格 | 描述 | Pixabay 搜索词 | 适合人群 |
|
||||||
|
|------|------|---------------|---------|
|
||||||
|
| **Lo-Fi** | 慵懒电子,流行选择 | `lofi calm`, `lofi study`, `lofi chill` | 年轻人 |
|
||||||
|
| **冥想轻音乐** | 钢琴 + 自然音,放松 | `meditation`, `calm piano`, `relaxing` | 中老年 |
|
||||||
|
| **轻爵士** | 优雅放松,有质感 | `light jazz`, `cafe jazz`, `bossa nova` | 通用 |
|
||||||
|
| **自然环境音** | 鸟叫/流水/雨声 | `nature ambient`, `forest sounds`, `rain bgm` | 喜欢自然的用户 |
|
||||||
|
| **东方禅意** | 古筝/笛子,养生 | `chinese zen`, `oriental calm`, `guqin` | 养生主题契合 |
|
||||||
|
|
||||||
|
### 推荐场景搭配
|
||||||
|
- **轻度档位 (60 BPM)**: 冥想轻音乐 (节奏舒缓)
|
||||||
|
- **中度档位 (80 BPM)**: 轻爵士 / Lo-Fi (适中)
|
||||||
|
- **重度档位 (100 BPM)**: 轻快电子 / 健身流行
|
||||||
|
|
||||||
|
### 技术要求
|
||||||
|
- **格式**: mp3
|
||||||
|
- **时长**: 30s - 2min (循环播放)
|
||||||
|
- **比特率**: 128 kbps (单声道 64 kbps 也可)
|
||||||
|
- **大小**: < 500 KB
|
||||||
|
- **循环点**: 选择起止点接近的片段,避免明显接缝
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
```
|
||||||
|
bgm-grip-light.mp3 # 轻度档位 BGM
|
||||||
|
bgm-grip-medium.mp3 # 中度档位 BGM (可选)
|
||||||
|
bgm-grip-heavy.mp3 # 重度档位 BGM (可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
或简化:
|
||||||
|
```
|
||||||
|
bgm-grip-default.mp3 # 默认 BGM (所有档位通用)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎙️ 四、语音鼓励 (P1 - 推荐)
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
达成奖励时播放鼓励语音,增强成就感。
|
||||||
|
|
||||||
|
### 两种方案
|
||||||
|
|
||||||
|
#### 方案 A: 小米 MiMo TTS 自动生成 (推荐)
|
||||||
|
|
||||||
|
项目已有 `useVoiceCoach.ts` + `generate-voice.mjs` 脚本支持。
|
||||||
|
|
||||||
|
**操作步骤**:
|
||||||
|
1. 申请小米 MiMo API Key: https://platform.xiaomimimo.com/
|
||||||
|
2. 配置环境变量: `export MIMO_API_KEY=sk-xxx`
|
||||||
|
3. 在 `generate-voice.mjs` 添加握力环话术
|
||||||
|
4. 运行脚本自动生成
|
||||||
|
|
||||||
|
**话术清单**:
|
||||||
|
```javascript
|
||||||
|
const gripPrompts = [
|
||||||
|
{ id: 'grip-egg', text: '握力不错!' },
|
||||||
|
{ id: 'grip-walnut', text: '力量惊人!' },
|
||||||
|
{ id: 'grip-can', text: '太强了!' },
|
||||||
|
{ id: 'grip-balloon', text: '完美!继续保持!' },
|
||||||
|
{ id: 'grip-start', text: '开始训练!' },
|
||||||
|
{ id: 'grip-pause', text: '休息一下!' },
|
||||||
|
{ id: 'grip-encourage-1', text: '加油,再来!' },
|
||||||
|
{ id: 'grip-encourage-2', text: '坚持住!' },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**音色推荐**: `茉莉` (清爽利落,有"运动博主"感)
|
||||||
|
|
||||||
|
#### 方案 B: 手工录制
|
||||||
|
|
||||||
|
如果不想用 TTS,可以自己录制(质感更好,但费时):
|
||||||
|
- 工具: 手机录音 / Audacity
|
||||||
|
- 环境: 安静的房间,距离麦克风 15-30cm
|
||||||
|
- 后期: 去噪 + 标准化音量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 五、采集工具推荐
|
||||||
|
|
||||||
|
### 在线裁剪
|
||||||
|
- [audiotrimmer.com](https://audiotrimmer.com) - 简单的 mp3 裁剪
|
||||||
|
- [mp3cut.net](https://mp3cut.net) - 功能更全
|
||||||
|
|
||||||
|
### 桌面工具(免费)
|
||||||
|
- **Audacity** (Mac/Windows/Linux) - 专业级,可以剪辑、降噪、标准化
|
||||||
|
- **Logic Pro** (Mac, 付费) - 更专业的音频处理
|
||||||
|
|
||||||
|
### 音量标准化
|
||||||
|
统一音量(避免有的太响有的太轻):
|
||||||
|
```bash
|
||||||
|
# 使用 ffmpeg (Mac: brew install ffmpeg)
|
||||||
|
ffmpeg -i input.mp3 -af "loudnorm=I=-16:LRA=11:TP=-1.5" output.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 Audacity 里: Effect → Normalize (峰值 -3dB)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ☁️ 六、上传到 CDN
|
||||||
|
|
||||||
|
### 项目使用的 COS 信息
|
||||||
|
- **域名**: `gz-1349751149.cos.ap-guangzhou.myqcloud.com`
|
||||||
|
- **目录建议**: `uploads/training/grip-ring/`
|
||||||
|
|
||||||
|
### 上传途径
|
||||||
|
1. **腾讯云控制台**: https://console.cloud.tencent.com/cos
|
||||||
|
2. **管理后台** (如果项目有上传入口)
|
||||||
|
3. **找团队 COS 管理员上传**
|
||||||
|
|
||||||
|
### 上传后获得的 URL 格式
|
||||||
|
```
|
||||||
|
https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/training/grip-ring/crush-egg.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 七、最终采集清单 (一次性收齐)
|
||||||
|
|
||||||
|
### 最小可行版本 (MVP) - 4 个文件
|
||||||
|
```
|
||||||
|
☐ grip-tick-wood.mp3 # 节拍音 - 木鱼
|
||||||
|
☐ crush-egg.mp3 # 鸡蛋
|
||||||
|
☐ crush-walnut.mp3 # 核桃
|
||||||
|
☐ crush-can.mp3 # 易拉罐
|
||||||
|
☐ crush-balloon.mp3 # 气球
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整版本 - 10 个文件
|
||||||
|
```
|
||||||
|
节拍音 (3 选 1 或全部):
|
||||||
|
☐ grip-tick-wood.mp3
|
||||||
|
☐ grip-tick-heart.mp3
|
||||||
|
☐ grip-tick-soft.mp3
|
||||||
|
|
||||||
|
捏碎音效 (必需 4 个):
|
||||||
|
☐ crush-egg.mp3
|
||||||
|
☐ crush-walnut.mp3
|
||||||
|
☐ crush-can.mp3
|
||||||
|
☐ crush-balloon.mp3
|
||||||
|
|
||||||
|
BGM (1-3 首):
|
||||||
|
☐ bgm-grip-default.mp3
|
||||||
|
☐ bgm-grip-meditation.mp3 (可选)
|
||||||
|
☐ bgm-grip-lofi.mp3 (可选)
|
||||||
|
|
||||||
|
语音鼓励 (TTS 自动生成,8 个):
|
||||||
|
☐ grip-egg.mp3
|
||||||
|
☐ grip-walnut.mp3
|
||||||
|
☐ grip-can.mp3
|
||||||
|
☐ grip-balloon.mp3
|
||||||
|
☐ grip-start.mp3
|
||||||
|
☐ grip-pause.mp3
|
||||||
|
☐ grip-encourage-1.mp3
|
||||||
|
☐ grip-encourage-2.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 八、采集完成后
|
||||||
|
|
||||||
|
把所有 URL 整理给我,我会:
|
||||||
|
1. 更新 `grip-ring.vue` 中的 REWARDS 配置 (替换捏碎音效 URL)
|
||||||
|
2. 创建/更新 `useMetronome.ts` 的音色配置 (支持切换节拍音色)
|
||||||
|
3. 在 `grip-ring.vue` 集成 `useTrainingBgm` (添加 BGM)
|
||||||
|
4. 在 `grip-ring.vue` 集成 `useVoiceCoach` (添加语音鼓励)
|
||||||
|
5. 在 UI 上添加音色/BGM 切换选项 (可选)
|
||||||
|
|
||||||
|
预计代码改动: 50-100 行,1-2 小时工作量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 参考链接
|
||||||
|
|
||||||
|
- [Pixabay 音效](https://pixabay.com/sound-effects/) - 免费可商用
|
||||||
|
- [Pixabay 音乐](https://pixabay.com/music/) - 免费可商用
|
||||||
|
- [Freesound](https://freesound.org) - 大量音效,部分需署名
|
||||||
|
- [Mixkit](https://mixkit.co/free-sound-effects/) - 免费音效库
|
||||||
|
- [Bensound](https://bensound.com) - 免费音乐(需署名)
|
||||||
|
- [Free Music Archive](https://freemusicarchive.org) - 免费音乐
|
||||||
|
|
||||||
|
**版权提示**: 务必确认音效/音乐是"免费商用"(Public Domain 或 CC0),避免侵权风险。
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 卡路里计算工具
|
||||||
|
* 基于标准 MET (Metabolic Equivalent of Task) 公式
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FoodItem {
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
calories: number
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoodComparison {
|
||||||
|
food: FoodItem
|
||||||
|
ratio: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalorieCalculationConfig {
|
||||||
|
totalReps: number
|
||||||
|
bpm: number
|
||||||
|
userWeight?: number // 默认 60kg
|
||||||
|
}
|
||||||
|
|
||||||
|
// 食物卡路里数据库
|
||||||
|
export const FOOD_CALORIES: Record<string, FoodItem> = {
|
||||||
|
apple: { name: '苹果', emoji: '🍎', calories: 52, unit: '个(100g)' },
|
||||||
|
egg: { name: '鸡蛋', emoji: '🥚', calories: 70, unit: '个' },
|
||||||
|
chocolate: { name: '巧克力', emoji: '🍫', calories: 54, unit: '块(10g)' },
|
||||||
|
banana: { name: '香蕉', emoji: '🍌', calories: 89, unit: '根' },
|
||||||
|
rice: { name: '米饭', emoji: '🍚', calories: 116, unit: '碗(100g)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算卡路里消耗
|
||||||
|
* 公式:卡路里 = MET × 体重(kg) × 时长(小时)
|
||||||
|
*
|
||||||
|
* @param config 计算配置
|
||||||
|
* @returns 卡路里消耗(kcal,保留一位小数)
|
||||||
|
*/
|
||||||
|
export function calculateCalories(config: CalorieCalculationConfig): number {
|
||||||
|
const MET = 3.5 // 握力环训练的标准 MET 值
|
||||||
|
const weight = config.userWeight || 60 // 默认 60kg
|
||||||
|
|
||||||
|
// 边界情况:无效输入
|
||||||
|
if (config.totalReps <= 0 || config.bpm <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsPerRep = 60 / config.bpm
|
||||||
|
const activeTimeHours = (config.totalReps * secondsPerRep) / 3600
|
||||||
|
|
||||||
|
const calories = MET * weight * activeTimeHours
|
||||||
|
return Math.round(calories * 10) / 10 // 保留一位小数
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动匹配最接近的食物对比
|
||||||
|
*
|
||||||
|
* @param burnedCalories 消耗的卡路里
|
||||||
|
* @returns 食物对比信息
|
||||||
|
*/
|
||||||
|
export function getFoodComparison(burnedCalories: number): FoodComparison {
|
||||||
|
const foods = Object.values(FOOD_CALORIES)
|
||||||
|
|
||||||
|
// 边界情况:食物数据库为空
|
||||||
|
if (foods.length === 0) {
|
||||||
|
throw new Error('食物数据库为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边界情况:卡路里为 0 或负数
|
||||||
|
if (burnedCalories <= 0) {
|
||||||
|
return {
|
||||||
|
food: foods[0],
|
||||||
|
ratio: 0,
|
||||||
|
message: `暂无消耗`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到卡路里最接近的食物
|
||||||
|
const closest = foods.reduce((prev, curr) => {
|
||||||
|
const prevDiff = Math.abs(prev.calories - burnedCalories)
|
||||||
|
const currDiff = Math.abs(curr.calories - burnedCalories)
|
||||||
|
return currDiff < prevDiff ? curr : prev
|
||||||
|
})
|
||||||
|
|
||||||
|
const ratio = burnedCalories / closest.calories
|
||||||
|
const ratioRounded = Math.round(ratio * 100) / 100 // 保留两位小数
|
||||||
|
|
||||||
|
// 格式化显示:ratio < 1 时显示分数形式更直观
|
||||||
|
let displayText: string
|
||||||
|
if (ratioRounded < 1) {
|
||||||
|
displayText = `相当于 ${ratioRounded.toFixed(2)} 个${closest.name} ${closest.emoji}`
|
||||||
|
} else {
|
||||||
|
displayText = `相当于 ${ratioRounded.toFixed(2)} ${closest.unit}${closest.name} ${closest.emoji}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
food: closest,
|
||||||
|
ratio: ratioRounded,
|
||||||
|
message: displayText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时长(秒 → 分:秒)
|
||||||
|
*
|
||||||
|
* @param seconds 秒数
|
||||||
|
* @returns 格式化字符串(如 "3:45")
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import uni from '@dcloudio/vite-plugin-uni'
|
||||||
|
import Optimization from '@uni-ku/bundle-optimizer'
|
||||||
|
import { tongjiFoodAssetsPlugin } from './tongji/endless-game/build/tongjiFoodAssetsPlugin'
|
||||||
|
|
||||||
|
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||||
|
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
uni(),
|
||||||
|
Optimization({
|
||||||
|
enable: {
|
||||||
|
optimization: true,
|
||||||
|
'async-import': false,
|
||||||
|
'async-component': false,
|
||||||
|
},
|
||||||
|
dts: false,
|
||||||
|
logger: false,
|
||||||
|
}),
|
||||||
|
tongjiFoodAssetsPlugin(),
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
// 屏蔽 TUICallKit SDK 内部 @import / legacy-js-api 弃用警告
|
||||||
|
// 待 TUICallKit 升级 @use 语法后可移除
|
||||||
|
silenceDeprecations: ['legacy-js-api', 'import'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// dev 热重载卡顿修复:
|
||||||
|
// UNI_INPUT_DIR=$PWD 让工程根=源码目录, chokidar 会监听整个仓库,
|
||||||
|
// 包含 node_modules / dist(编译产物) / unpackage 等巨大目录, 导致每次热重载卡住,
|
||||||
|
// 写 dist 又触发监听抖动。这里收窄监听范围。
|
||||||
|
// 注意: 自定义 ignored 会覆盖 vite 默认值, 故 node_modules/.git 必须一并列出。
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
ignored: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/.git/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/unpackage/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 账号管理 API
|
||||||
|
export function apiAssetUserList(params: any) {
|
||||||
|
return request.get({ url: '/asset.AssetUser/lists', params })
|
||||||
|
}
|
||||||
|
export function apiAssetUserAdd(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetUser/add', params })
|
||||||
|
}
|
||||||
|
export function apiAssetUserEdit(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetUser/edit', params })
|
||||||
|
}
|
||||||
|
export function apiAssetUserDelete(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetUser/delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资源管理 API
|
||||||
|
export function apiAssetResourceList(params: any) {
|
||||||
|
return request.get({ url: '/asset.AssetResource/lists', params })
|
||||||
|
}
|
||||||
|
export function apiAssetResourceAdd(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetResource/add', params })
|
||||||
|
}
|
||||||
|
export function apiAssetResourceEdit(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetResource/edit', params })
|
||||||
|
}
|
||||||
|
export function apiAssetResourceDelete(params: any) {
|
||||||
|
return request.post({ url: '/asset.AssetResource/delete', params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function getSelfInputOverview(params: any) {
|
||||||
|
return request.get({ url: '/stats.self_input/overview', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
|
||||||
|
export function getSelfInputMediaSourceOptions() {
|
||||||
|
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiLists(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_yeji/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiAdd(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/add', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiEdit(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/edit', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiDetail(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_yeji/detail', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalYejiDelete(params: any) {
|
||||||
|
return request.post({ url: '/stats.personal_yeji/delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostLists(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_account_cost/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostAdd(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/add', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostEdit(data: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/edit', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostDetail(params: any) {
|
||||||
|
return request.get({ url: '/stats.personal_account_cost/detail', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personalAccountCostDelete(params: any) {
|
||||||
|
return request.post({ url: '/stats.personal_account_cost/delete', params })
|
||||||
|
}
|
||||||
@@ -96,10 +96,26 @@ export function yejiStatsRevisitBreakdown(params: {
|
|||||||
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
|
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 被指派数明细:与看板「被指派数」同口径;部门行传 dept_id,医助排行榜传 assistant_id */
|
||||||
|
export function yejiStatsAssignLines(params: {
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
dept_id?: number
|
||||||
|
assistant_id?: number
|
||||||
|
dept_ids?: string
|
||||||
|
channel_code?: string
|
||||||
|
tag_id?: string
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/stats.yejiStats/assignLines', params })
|
||||||
|
}
|
||||||
|
|
||||||
export function doctorDailyStatsOverview(params: {
|
export function doctorDailyStatsOverview(params: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
channel_code?: string
|
channel_code?: string
|
||||||
|
dept_ids?: number[] | string
|
||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.doctorDailyStats/overview', params })
|
return request.get({ url: '/stats.doctorDailyStats/overview', params })
|
||||||
}
|
}
|
||||||
@@ -179,3 +195,53 @@ export function commissionSettlementConfirmFinalize(params: Record<string, any>)
|
|||||||
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
||||||
|
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
||||||
|
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复诊接诊率:部门下拉(前端组树) */
|
||||||
|
export function revisitRateDeptOptions() {
|
||||||
|
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
||||||
|
export function revisitRateAssignLines(params: {
|
||||||
|
month?: string
|
||||||
|
dept_ids?: string
|
||||||
|
assistant_id?: number
|
||||||
|
dept_id?: number
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
||||||
|
export function revisitRateVisitOrderLines(params: {
|
||||||
|
month?: string
|
||||||
|
slot: number
|
||||||
|
dept_ids?: string
|
||||||
|
assistant_id?: number
|
||||||
|
dept_id?: number
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
|
||||||
|
export function autoAssignLogLists(params: Record<string, any>) {
|
||||||
|
return request.get({ url: '/stats.autoAssignLog/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量回退自动分配:将诊单医助撤回到自动分配前的原医助 */
|
||||||
|
export function autoAssignLogRollback(data: { ids: number[] }) {
|
||||||
|
return request.post({ url: '/stats.autoAssignLog/rollback', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 医助个人业绩概览 */
|
||||||
|
export function assistantPerformanceOverview(params: {
|
||||||
|
time_type?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ export function tcmDiagnosisDetail(params: any) {
|
|||||||
return request.get({ url: '/tcm.diagnosis/detail', params })
|
return request.get({ url: '/tcm.diagnosis/detail', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置复诊接诊率统计起始偏移(统计诊次=实单序号+偏移;1=二诊起,2=三诊起) */
|
||||||
|
export function tcmDiagnosisSetRevisitSlotStartOffset(params: {
|
||||||
|
id: number
|
||||||
|
revisit_slot_start_offset: number
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.diagnosis/setRevisitSlotStartOffset', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 诊单挂号 / 取消挂号 操作日志 */
|
/** 诊单挂号 / 取消挂号 操作日志 */
|
||||||
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
|
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
|
||||||
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
||||||
@@ -319,6 +327,16 @@ export function prescriptionEdit(params: any) {
|
|||||||
return request.post({ url: '/tcm.prescription/edit', params })
|
return request.post({ url: '/tcm.prescription/edit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅修正处方笺患者姓名、手机号与性别(不改审核状态),写入业务订单日志表 */
|
||||||
|
export function prescriptionPatchPatient(params: {
|
||||||
|
id: number
|
||||||
|
patient_name: string
|
||||||
|
phone: string
|
||||||
|
gender: number
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.prescription/patchPatient', params })
|
||||||
|
}
|
||||||
|
|
||||||
// 删除处方
|
// 删除处方
|
||||||
export function prescriptionDelete(params: { id: number }) {
|
export function prescriptionDelete(params: { id: number }) {
|
||||||
return request.post({ url: '/tcm.prescription/delete', params })
|
return request.post({ url: '/tcm.prescription/delete', params })
|
||||||
@@ -393,6 +411,11 @@ export function prescriptionOrderLogisticsTrace(params: {
|
|||||||
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
|
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 直接调用京东官方物流接口刷新轨迹并落库(绕过快递100缓存) */
|
||||||
|
export function prescriptionOrderLogisticsJdUpdate(params: { id: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/logisticsJdUpdate', params })
|
||||||
|
}
|
||||||
|
|
||||||
export function prescriptionOrderDetail(params: { id: number }) {
|
export function prescriptionOrderDetail(params: { id: number }) {
|
||||||
return request.get({ url: '/tcm.prescriptionOrder/detail', params })
|
return request.get({ url: '/tcm.prescriptionOrder/detail', params })
|
||||||
}
|
}
|
||||||
@@ -410,6 +433,17 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
|
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prescriptionOrderPatchPrescriptionUsage(params: {
|
||||||
|
id: number
|
||||||
|
times_per_day: number
|
||||||
|
usage_days: number
|
||||||
|
medication_days: number
|
||||||
|
aux_times_per_day?: number
|
||||||
|
aux_usage_days?: number
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
||||||
|
}
|
||||||
|
|
||||||
export function prescriptionOrderAuditPrescription(params: {
|
export function prescriptionOrderAuditPrescription(params: {
|
||||||
id: number
|
id: number
|
||||||
action: 'approve' | 'reject'
|
action: 'approve' | 'reject'
|
||||||
@@ -431,6 +465,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
|
||||||
|
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 撤回支付单审核 */
|
/** 撤回支付单审核 */
|
||||||
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
||||||
@@ -452,20 +491,38 @@ export function prescriptionOrderAddPayOrder(params: {
|
|||||||
order_type: number
|
order_type: number
|
||||||
pay_amount: number
|
pay_amount: number
|
||||||
pay_remark?: string
|
pay_remark?: string
|
||||||
|
completion_request?: number
|
||||||
|
/** 创建方式:fubei 付呗(默认) / express_cod 快递代收 */
|
||||||
|
pay_create_type?: 'fubei' | 'express_cod'
|
||||||
}) {
|
}) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||||
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) {
|
export function prescriptionOrderLinkPayOrder(params: {
|
||||||
|
id: number
|
||||||
|
pay_order_id?: number
|
||||||
|
pay_order_ids?: number[]
|
||||||
|
completion_request?: number
|
||||||
|
}) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
|
/** 已发货/已签收:仅提交完单申请(不新增/关联支付单) */
|
||||||
|
export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」) */
|
||||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 业务订单退款(须填写原因;refund_amount 可选,不传则退满可退上限) */
|
||||||
|
export function prescriptionOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 甘草药管家:中药处方下单(服务端 CTM_PREVIEW → CTM_SUBMIT_RECIPEL) */
|
/** 甘草药管家:中药处方下单(服务端 CTM_PREVIEW → CTM_SUBMIT_RECIPEL) */
|
||||||
export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
||||||
@@ -485,11 +542,28 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
|||||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手工新增操作日志(可选调整处方/支付单审核状态) */
|
||||||
|
export function prescriptionOrderAddLog(params: {
|
||||||
|
id: number
|
||||||
|
summary: string
|
||||||
|
prescription_audit_status?: number | ''
|
||||||
|
payment_slip_audit_status?: number | ''
|
||||||
|
prescription_audit_remark?: string
|
||||||
|
payment_slip_audit_remark?: string
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 修改订单金额 */
|
/** 修改订单金额 */
|
||||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置发货类型:gancao 甘草药房 / direct 洛阳药房 */
|
||||||
|
export function prescriptionOrderSetShipMode(params: { id: number; ship_mode: 'gancao' | 'direct' }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/setShipMode', params })
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 处方库 ==========
|
// ========== 处方库 ==========
|
||||||
|
|
||||||
// 处方库列表
|
// 处方库列表
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
:end-placeholder="endPlaceholder"
|
:end-placeholder="endPlaceholder"
|
||||||
:value-format="valueFormat"
|
:value-format="valueFormat"
|
||||||
clearable
|
clearable
|
||||||
|
@change="emit('change', $event)"
|
||||||
></el-date-picker>
|
></el-date-picker>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ const props = withDefaults(
|
|||||||
endPlaceholder: '结束时间'
|
endPlaceholder: '结束时间'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const emit = defineEmits(['update:startTime', 'update:endTime'])
|
const emit = defineEmits(['update:startTime', 'update:endTime', 'change'])
|
||||||
|
|
||||||
const content = computed<any>({
|
const content = computed<any>({
|
||||||
get: () => {
|
get: () => {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="variant === 'split'">
|
||||||
|
<el-form-item :label="label" :class="{ 'is-compact': compact }">
|
||||||
|
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
|
||||||
|
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
|
||||||
|
<el-radio-button label="today">今天</el-radio-button>
|
||||||
|
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||||
|
<el-radio-button label="week">最近7天</el-radio-button>
|
||||||
|
<el-radio-button label="month">本月</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="timeType === 'custom'">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRangeModel"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
:size="compact ? 'small' : 'default'"
|
||||||
|
:style="{ width: pickerWidth }"
|
||||||
|
@change="emit('custom-change')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
<el-form-item v-else :label="label" :class="{ 'is-compact': compact }">
|
||||||
|
<div class="list-time-filter-inline">
|
||||||
|
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
|
||||||
|
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
|
||||||
|
<el-radio-button label="today">今天</el-radio-button>
|
||||||
|
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||||
|
<el-radio-button label="week">最近7天</el-radio-button>
|
||||||
|
<el-radio-button label="month">本月</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-date-picker
|
||||||
|
v-if="timeType === 'custom'"
|
||||||
|
v-model="dateRangeModel"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
:size="compact ? 'small' : 'default'"
|
||||||
|
:style="{ width: pickerWidth }"
|
||||||
|
@change="emit('custom-change')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ListTimeType } from '@/hooks/useListTimeFilter'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string
|
||||||
|
/** split:与自录统计一致,两个 form-item;inline:处方订单筛选用单行 */
|
||||||
|
variant?: 'split' | 'inline'
|
||||||
|
pickerWidth?: string
|
||||||
|
/** 紧凑模式:small 尺寸 radio / 日期 */
|
||||||
|
compact?: boolean
|
||||||
|
/** 是否显示「全部」(不限时间)选项 */
|
||||||
|
showAll?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: '时间范围',
|
||||||
|
variant: 'split',
|
||||||
|
pickerWidth: '260px',
|
||||||
|
compact: false,
|
||||||
|
showAll: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeType = defineModel<ListTimeType>('timeType', { required: true })
|
||||||
|
const dateRangeModel = defineModel<string[]>('dateRange', { default: () => [] })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'time-type-change': []
|
||||||
|
'custom-change': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list-time-filter-inline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.is-compact.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -102,8 +102,8 @@ export default defineComponent({
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const fileList = ref<any[]>([])
|
const fileList = ref<any[]>([])
|
||||||
|
|
||||||
// 仅 video + direct 时才接管 http-request
|
// 仅 video/voice + direct 时才接管 http-request
|
||||||
const useDirect = computed(() => props.direct && props.type === 'video')
|
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
|
||||||
|
|
||||||
const handleProgress = () => {
|
const handleProgress = () => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -151,6 +151,8 @@ export default defineComponent({
|
|||||||
return '.jpg,.png,.gif,.jpeg,.ico'
|
return '.jpg,.png,.gif,.jpeg,.ico'
|
||||||
case 'video':
|
case 'video':
|
||||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||||
|
case 'voice':
|
||||||
|
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||||
default:
|
default:
|
||||||
return '*'
|
return '*'
|
||||||
}
|
}
|
||||||
@@ -162,7 +164,7 @@ export default defineComponent({
|
|||||||
try {
|
try {
|
||||||
const data = await uploadVideoDirectToCos({
|
const data = await uploadVideoDirectToCos({
|
||||||
file: options.file,
|
file: options.file,
|
||||||
type: 'video',
|
type: props.type as any,
|
||||||
cid: Number((options.data as any)?.cid ?? 0),
|
cid: Number((options.data as any)?.cid ?? 0),
|
||||||
onProgress(info) {
|
onProgress(info) {
|
||||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
export type ListTimeType = 'all' | 'today' | 'yesterday' | 'week' | 'month' | 'custom'
|
||||||
|
|
||||||
|
export function formatYmd(d: Date): string {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表时间筛选:month 为本自然月 1 日~月末 */
|
||||||
|
export function resolveListTimeRange(timeType: ListTimeType, dateRange: string[]): [string, string] {
|
||||||
|
const today = formatYmd(new Date())
|
||||||
|
|
||||||
|
switch (timeType) {
|
||||||
|
case 'all':
|
||||||
|
return ['', '']
|
||||||
|
case 'yesterday': {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 1)
|
||||||
|
const y = formatYmd(d)
|
||||||
|
return [y, y]
|
||||||
|
}
|
||||||
|
case 'week': {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
start.setDate(start.getDate() - 6)
|
||||||
|
return [formatYmd(start), formatYmd(end)]
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||||
|
return [formatYmd(start), formatYmd(end)]
|
||||||
|
}
|
||||||
|
case 'custom': {
|
||||||
|
let start = dateRange[0] || today
|
||||||
|
let end = dateRange[1] || today
|
||||||
|
if (start > end) {
|
||||||
|
;[start, end] = [end, start]
|
||||||
|
}
|
||||||
|
return [start, end]
|
||||||
|
}
|
||||||
|
case 'today':
|
||||||
|
default:
|
||||||
|
return [today, today]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListTimeFilter(defaultType: ListTimeType = 'today') {
|
||||||
|
const time_type = ref<ListTimeType>(defaultType)
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
|
||||||
|
const resolve = () => resolveListTimeRange(time_type.value, dateRange.value)
|
||||||
|
|
||||||
|
const handleTimeTypeChange = (onQuery: () => void) => {
|
||||||
|
if (time_type.value !== 'custom') {
|
||||||
|
dateRange.value = []
|
||||||
|
onQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomDateChange = (onQuery: () => void) => {
|
||||||
|
if (dateRange.value.length === 2) {
|
||||||
|
onQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTimeFilter = () => {
|
||||||
|
time_type.value = defaultType
|
||||||
|
dateRange.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyCustomRange = (start: string, end: string) => {
|
||||||
|
if (!start || !end) return
|
||||||
|
time_type.value = 'custom'
|
||||||
|
dateRange.value = [start, end]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time_type,
|
||||||
|
dateRange,
|
||||||
|
resolve,
|
||||||
|
handleTimeTypeChange,
|
||||||
|
handleCustomDateChange,
|
||||||
|
resetTimeFilter,
|
||||||
|
applyCustomRange,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<template>
|
||||||
|
<div class="asset-resource-container">
|
||||||
|
<!-- 搜索区域 -->
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form class="ls-form" :model="searchData" inline>
|
||||||
|
<el-form-item class="w-[280px]" label="标题">
|
||||||
|
<el-input
|
||||||
|
placeholder="请输入标题关键词"
|
||||||
|
v-model="searchData.title"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上传时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="searchData.dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
clearable
|
||||||
|
style="width: 280px;"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 列表区域 -->
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||||
|
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
|
||||||
|
批量删除
|
||||||
|
</el-button>
|
||||||
|
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||||
|
<el-tab-pane label="图片" name="1"></el-tab-pane>
|
||||||
|
<el-tab-pane label="视频" name="2"></el-tab-pane>
|
||||||
|
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="55" />
|
||||||
|
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||||
|
<el-table-column prop="title" label="标题" min-width="80" />
|
||||||
|
<el-table-column label="预览" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-image v-if="row.type == 1" :src="row.file_url" style="width: 50px; height: 50px;" :preview-src-list="[row.file_url]" preview-teleported />
|
||||||
|
<el-link v-else :href="row.file_url" target="_blank" type="primary">点击预览</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="关联账号" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="!row.users?.length" type="info" size="small">公开资源</el-tag>
|
||||||
|
<span v-else>{{ row.users?.map((u: any) => u.phone).join(', ') }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="上传时间" width="180" />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.page_no"
|
||||||
|
v-model:page-size="queryParams.page_size"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="getList"
|
||||||
|
@current-change="getList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新增/编辑资源弹窗 -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="computedRules" label-width="100px">
|
||||||
|
<el-form-item label="资源类型" prop="type" v-if="!isEdit">
|
||||||
|
<el-radio-group v-model="formData.type">
|
||||||
|
<el-radio :label="1">图片</el-radio>
|
||||||
|
<el-radio :label="2">视频</el-radio>
|
||||||
|
<el-radio :label="3">语音</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标题" prop="title">
|
||||||
|
<el-input v-model="formData.title" placeholder="请输入资源标题" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
|
||||||
|
<div v-if="formData.type === 1" class="w-full">
|
||||||
|
<material-picker v-model="formData.file_url" :limit="9" type="image" />
|
||||||
|
<div style="font-size: 12px; color: #909399; margin-top: 4px;">支持批量上传多张图片,一次最多 9 张,每张图片将生成一条独立资源(多张时标题自动追加序号)</div>
|
||||||
|
</div>
|
||||||
|
<upload v-else-if="formData.type === 2" type="video" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||||
|
<el-button type="primary">上传视频 (OSS直传)</el-button>
|
||||||
|
</upload>
|
||||||
|
<upload v-else-if="formData.type === 3" type="voice" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||||
|
<el-button type="primary">上传语音 (OSS直传)</el-button>
|
||||||
|
</upload>
|
||||||
|
<div v-if="formData.file_url && formData.type !== 1" class="ml-4 text-primary truncate max-w-xs" :title="formData.file_url">
|
||||||
|
已上传: {{ formData.file_url }}
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关联账号" prop="user_ids">
|
||||||
|
<el-select v-model="formData.user_ids" multiple filterable placeholder="不选择则为公开资源" clearable style="width: 100%;">
|
||||||
|
<el-option v-for="item in userOptions" :key="item.id" :label="item.phone" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<div style="font-size: 12px; color: #909399; margin-top: 4px;">不选择账号则资源为公开资源,所有人可见</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm" :loading="submitLoading">{{ isEdit ? '保存修改' : '确定发布' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { apiAssetResourceList, apiAssetResourceAdd, apiAssetResourceEdit, apiAssetResourceDelete, apiAssetUserList } from '@/api/asset'
|
||||||
|
import MaterialPicker from '@/components/material/picker.vue'
|
||||||
|
import Upload from '@/components/upload/index.vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tableData = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const searchData = reactive<any>({
|
||||||
|
title: '',
|
||||||
|
dateRange: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryParams = reactive<any>({
|
||||||
|
type: '1',
|
||||||
|
page_no: 1,
|
||||||
|
page_size: 15
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const editId = ref(0)
|
||||||
|
|
||||||
|
const userOptions = ref<any[]>([])
|
||||||
|
|
||||||
|
const formData = reactive<any>({
|
||||||
|
type: 1,
|
||||||
|
title: '',
|
||||||
|
file_url: '',
|
||||||
|
cover_url: '',
|
||||||
|
user_ids: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const validateFileUrl = (_rule: any, value: any, callback: any) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (!value.length) return callback(new Error('请上传资源文件'))
|
||||||
|
} else if (!value) {
|
||||||
|
return callback(new Error('请输入或上传文件获取链接'))
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedRules = computed(() => {
|
||||||
|
if (isEdit.value) {
|
||||||
|
return {
|
||||||
|
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||||
|
file_url: [{ required: true, validator: validateFileUrl, trigger: 'change' }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildQueryParams = () => {
|
||||||
|
const params: any = { ...queryParams }
|
||||||
|
if (searchData.title) {
|
||||||
|
params.title = searchData.title
|
||||||
|
}
|
||||||
|
if (searchData.dateRange && searchData.dateRange.length === 2) {
|
||||||
|
params.start_time = searchData.dateRange[0]
|
||||||
|
params.end_time = searchData.dateRange[1]
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiAssetResourceList(buildQueryParams())
|
||||||
|
tableData.value = res.lists
|
||||||
|
total.value = res.count
|
||||||
|
selectedIds.value = []
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUserOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiAssetUserList({ page_no: 1, page_size: 9999 })
|
||||||
|
userOptions.value = res.lists
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
queryParams.page_no = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
searchData.title = ''
|
||||||
|
searchData.dateRange = []
|
||||||
|
queryParams.page_no = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTabChange = () => {
|
||||||
|
queryParams.page_no = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
isEdit.value = false
|
||||||
|
editId.value = 0
|
||||||
|
Object.assign(formData, { type: parseInt(queryParams.type), title: '', file_url: '', cover_url: '', user_ids: [] })
|
||||||
|
fetchUserOptions()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: any) => {
|
||||||
|
isEdit.value = true
|
||||||
|
editId.value = row.id
|
||||||
|
Object.assign(formData, {
|
||||||
|
type: row.type,
|
||||||
|
title: row.title,
|
||||||
|
file_url: row.file_url,
|
||||||
|
cover_url: row.cover_url || '',
|
||||||
|
user_ids: row.users?.map((u: any) => u.id) || []
|
||||||
|
})
|
||||||
|
fetchUserOptions()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadSuccess = (response: any) => {
|
||||||
|
formData.file_url = response?.data?.uri || response?.data?.url || ''
|
||||||
|
ElMessage.success('上传成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该资源吗?用户将无法再看到。', '提示', { type: 'warning' })
|
||||||
|
await apiAssetResourceDelete({ id: row.id })
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
getList()
|
||||||
|
} catch (e) {
|
||||||
|
// canceled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectionChange = (rows: any[]) => {
|
||||||
|
selectedIds.value = rows.map((row) => Number(row.id)).filter((id) => id > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (!selectedIds.value.length) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个资源吗?用户将无法再看到。`, '提示', { type: 'warning' })
|
||||||
|
await apiAssetResourceDelete({ id: selectedIds.value })
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
getList()
|
||||||
|
} catch (e) {
|
||||||
|
// canceled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await apiAssetResourceEdit({
|
||||||
|
id: editId.value,
|
||||||
|
title: formData.title,
|
||||||
|
user_ids: formData.user_ids
|
||||||
|
})
|
||||||
|
ElMessage.success('编辑成功')
|
||||||
|
} else {
|
||||||
|
await apiAssetResourceAdd(formData)
|
||||||
|
ElMessage.success('上传分发成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
getList()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div class="asset-user-container">
|
||||||
|
<!-- 搜索区域 -->
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form class="ls-form" :model="searchData" inline>
|
||||||
|
<el-form-item class="w-[280px]" label="手机号">
|
||||||
|
<el-input
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
v-model="searchData.phone"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 列表区域 -->
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<div class="mb-4">
|
||||||
|
<el-button type="primary" @click="handleAdd">新增账号</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="tableData" v-loading="loading">
|
||||||
|
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||||
|
<el-table-column prop="phone" label="手机号" />
|
||||||
|
<el-table-column label="备注" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="flex items-center gap-1 min-w-0">
|
||||||
|
<span class="truncate text-gray-600" :title="row.remark || ''">
|
||||||
|
{{ row.remark || '—' }}
|
||||||
|
</span>
|
||||||
|
<el-tooltip content="编辑备注" placement="top">
|
||||||
|
<el-icon class="shrink-0 cursor-pointer text-gray-400 hover:text-primary" @click="openQuickRemark(row)">
|
||||||
|
<Edit />
|
||||||
|
</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.status"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
active-text="正常"
|
||||||
|
inactive-text="禁用"
|
||||||
|
inline-prompt
|
||||||
|
@change="handleStatusChange(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.page_no"
|
||||||
|
v-model:page-size="queryParams.page_size"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="getList"
|
||||||
|
@current-change="getList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 编辑/新增弹窗 -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input v-model="formData.phone" placeholder="请输入手机号(作为登录账号)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="可填写用户相关信息,仅管理员可见"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 快捷备注 -->
|
||||||
|
<el-dialog v-model="remarkDialogVisible" title="快捷备注" width="420px" destroy-on-close>
|
||||||
|
<div class="text-sm text-gray-500 mb-3">账号:{{ remarkTargetPhone }}</div>
|
||||||
|
<el-input
|
||||||
|
v-model="remarkDraft"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="可填写用户相关信息,仅管理员可见"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="remarkDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="remarkSaving" @click="saveQuickRemark">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Edit } from '@element-plus/icons-vue'
|
||||||
|
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tableData = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const searchData = reactive({
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryParams = reactive<any>({
|
||||||
|
page_no: 1,
|
||||||
|
page_size: 15
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('')
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const remarkDialogVisible = ref(false)
|
||||||
|
const remarkSaving = ref(false)
|
||||||
|
const remarkDraft = ref('')
|
||||||
|
const remarkTargetId = ref<number | string>('')
|
||||||
|
const remarkTargetPhone = ref('')
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: '',
|
||||||
|
phone: '',
|
||||||
|
password: '',
|
||||||
|
remark: '',
|
||||||
|
status: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = { ...queryParams, ...searchData }
|
||||||
|
const res = await apiAssetUserList(params)
|
||||||
|
tableData.value = res.lists
|
||||||
|
total.value = res.count
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
queryParams.page_no = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
searchData.phone = ''
|
||||||
|
queryParams.page_no = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStatusChange = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await apiAssetUserEdit({ id: row.id, status: row.status })
|
||||||
|
ElMessage.success(row.status === 1 ? '已启用' : '已禁用')
|
||||||
|
} catch (e) {
|
||||||
|
// 失败时回滚状态
|
||||||
|
row.status = row.status === 1 ? 0 : 1
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialogTitle.value = '新增账号'
|
||||||
|
Object.assign(formData, { id: '', phone: '', password: '', remark: '', status: 1 })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (row: any) => {
|
||||||
|
dialogTitle.value = '编辑账号'
|
||||||
|
Object.assign(formData, { id: row.id, phone: row.phone, password: '', remark: row.remark || '', status: row.status })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openQuickRemark = (row: any) => {
|
||||||
|
remarkTargetId.value = row.id
|
||||||
|
remarkTargetPhone.value = row.phone || ''
|
||||||
|
remarkDraft.value = row.remark || ''
|
||||||
|
remarkDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveQuickRemark = async () => {
|
||||||
|
if (!remarkTargetId.value) return
|
||||||
|
remarkSaving.value = true
|
||||||
|
try {
|
||||||
|
await apiAssetUserEdit({ id: remarkTargetId.value, remark: remarkDraft.value })
|
||||||
|
const target = tableData.value.find((item: any) => item.id === remarkTargetId.value)
|
||||||
|
if (target) {
|
||||||
|
target.remark = remarkDraft.value
|
||||||
|
}
|
||||||
|
ElMessage.success('备注已保存')
|
||||||
|
remarkDialogVisible.value = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
remarkSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
|
||||||
|
await apiAssetUserDelete({ id: row.id })
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
getList()
|
||||||
|
} catch (e) {
|
||||||
|
// canceled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
if (formData.id) {
|
||||||
|
await apiAssetUserEdit(formData)
|
||||||
|
} else {
|
||||||
|
await apiAssetUserAdd(formData)
|
||||||
|
}
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
getList()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* 处方业务订单——纯展示/格式化工具
|
||||||
|
*
|
||||||
|
* 唯一数据源:order_list.vue(处方业务订单)、PatientOrderList.vue(诊单业务订单 Tab)
|
||||||
|
* 与 PrescriptionOrderDetailDrawer.vue(共享详情抽屉)共用,禁止在页面内重复定义同名函数,
|
||||||
|
* 否则两个详情页面会再次出现文案/口径漂移。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 与诊间医助角色 ID 一致(server 角色表) */
|
||||||
|
export const TCM_ASSISTANT_ROLE_ID = 2
|
||||||
|
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||||
|
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||||
|
|
||||||
|
export function formatTime(v: unknown) {
|
||||||
|
if (v === null || v === undefined || v === '') return '—'
|
||||||
|
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||||
|
const d = new Date(v * 1000)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(v: unknown) {
|
||||||
|
const n = Number(v)
|
||||||
|
if (Number.isNaN(n)) return '—'
|
||||||
|
return n.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOrderTime(v: unknown) {
|
||||||
|
if (v === null || v === undefined || v === '') return '—'
|
||||||
|
if (typeof v === 'string' && String(v).includes('-')) return String(v)
|
||||||
|
return formatTime(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feeTypeText(t: number | undefined) {
|
||||||
|
const m: Record<number, string> = {
|
||||||
|
1: '挂号费',
|
||||||
|
2: '问诊费',
|
||||||
|
3: '药品费用',
|
||||||
|
4: '首付费用',
|
||||||
|
5: '尾款费用',
|
||||||
|
6: '其他费用',
|
||||||
|
7: '全部费用',
|
||||||
|
8: '驼奶费用'
|
||||||
|
}
|
||||||
|
return m[Number(t)] ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fulfillmentText(s: number | undefined) {
|
||||||
|
const m: Record<number, string> = {
|
||||||
|
1: '待双审通过',
|
||||||
|
2: '待发货',
|
||||||
|
3: '已完成',
|
||||||
|
4: '已取消',
|
||||||
|
5: '已发货',
|
||||||
|
6: '已签收',
|
||||||
|
7: '进行中',
|
||||||
|
8: '暂不制药',
|
||||||
|
9: '拒收',
|
||||||
|
10: '退款',
|
||||||
|
11: '保留药方',
|
||||||
|
12: '制药缓发'
|
||||||
|
}
|
||||||
|
return m[Number(s)] ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||||
|
const n = Number(s)
|
||||||
|
if (n === 3) return 'success'
|
||||||
|
if (n === 6) return 'success'
|
||||||
|
if (n === 5) return 'primary'
|
||||||
|
if (n === 4) return 'danger'
|
||||||
|
if (n === 2) return 'warning'
|
||||||
|
if (n === 7) return 'warning'
|
||||||
|
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||||
|
if (n === 9 || n === 10) return 'danger'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderStatusText(s: number | undefined) {
|
||||||
|
const m: Record<number, string> = {
|
||||||
|
1: '待支付',
|
||||||
|
2: '已支付',
|
||||||
|
3: '已取消',
|
||||||
|
4: '已退款',
|
||||||
|
5: '待审核'
|
||||||
|
}
|
||||||
|
return m[Number(s)] ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
|
const n = Number(s)
|
||||||
|
if (n === 2) return 'success'
|
||||||
|
if (n === 1) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumerRxAuditText(s: number | undefined) {
|
||||||
|
const n = Number(s)
|
||||||
|
if (n === 1) return '已通过'
|
||||||
|
if (n === 2) return '已驳回'
|
||||||
|
return '待审核'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
|
const n = Number(s)
|
||||||
|
if (n === 1) return 'success'
|
||||||
|
if (n === 2) return 'danger'
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expressCompanyLabel(v: unknown) {
|
||||||
|
const s = String(v || '').toLowerCase()
|
||||||
|
if (s === 'sf') return '顺丰速运'
|
||||||
|
if (s === 'jd') return '京东快递'
|
||||||
|
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||||
|
return '自动识别'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logActionText(act: string) {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
create: '创建',
|
||||||
|
edit: '编辑',
|
||||||
|
audit_rx_approve: '处方审核',
|
||||||
|
audit_rx_reject: '处方审核',
|
||||||
|
audit_pay_approve: '支付审核',
|
||||||
|
audit_pay_reject: '支付审核',
|
||||||
|
fill_tracking: '填快递单',
|
||||||
|
ship: '确认发货',
|
||||||
|
withdraw: '撤销',
|
||||||
|
link_pay_order: '关联支付单',
|
||||||
|
completion_request: '完单申请',
|
||||||
|
auto_complete: '自动完成',
|
||||||
|
revoke_rx_audit: '撤回处方审核',
|
||||||
|
revoke_pay_audit: '撤回支付审核',
|
||||||
|
gancao_submit: '甘草下单',
|
||||||
|
patch_rx_patient: '处方患者信息',
|
||||||
|
patch_rx_usage: '服用参数',
|
||||||
|
update_amount: '修改订单金额',
|
||||||
|
complete: '完成订单',
|
||||||
|
refund: '退款',
|
||||||
|
manual_log: '手工备注',
|
||||||
|
assign_assistant: '改派医助',
|
||||||
|
add_pay_order: '补齐支付单',
|
||||||
|
set_ship_mode: '发货类型'
|
||||||
|
}
|
||||||
|
return m[act] || act
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
|
||||||
|
export function auditStatusText(s: number | undefined) {
|
||||||
|
if (s === 1) return '已通过'
|
||||||
|
if (s === 2) return '已驳回'
|
||||||
|
return '待审核'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
|
||||||
|
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||||
|
const createType = String(row?.create_type || '')
|
||||||
|
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||||
|
if (createType === 'fubei') return '付呗'
|
||||||
|
if (createType === 'express_cod') return '快递代收'
|
||||||
|
const paymentMethod = String(row?.payment_method || '')
|
||||||
|
const methodMap: Record<string, string> = {
|
||||||
|
alipay: '支付宝',
|
||||||
|
wechat: '微信',
|
||||||
|
wechat_work: '企业微信',
|
||||||
|
fubei: '付呗',
|
||||||
|
express_cod: '快递代收',
|
||||||
|
manual: '手动确认到账'
|
||||||
|
}
|
||||||
|
if (paymentMethod && methodMap[paymentMethod]) {
|
||||||
|
return methodMap[paymentMethod]
|
||||||
|
}
|
||||||
|
return '普通订单'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBizPhone(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
return String(v).replace(/\s/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
|
||||||
|
const a = normalizeBizPhone(recipient)
|
||||||
|
const b = normalizeBizPhone(rxPhone)
|
||||||
|
if (!a || !b) return false
|
||||||
|
return a !== b
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
||||||
|
if (!raw) return []
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((x: any) => ({
|
||||||
|
name: String(x?.name ?? '').trim(),
|
||||||
|
dosage: Number(x?.dosage) || 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlipFormulaType = '主方' | '辅方'
|
||||||
|
|
||||||
|
export interface SlipAuxUsageForm {
|
||||||
|
dosage_amount?: number
|
||||||
|
dosage_bag_count: number
|
||||||
|
need_decoction: boolean
|
||||||
|
bags_per_dose: number
|
||||||
|
times_per_day: number
|
||||||
|
usage_days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
|
||||||
|
return v === '辅方' ? '辅方' : '主方'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
|
||||||
|
if (prescriptionType === '饮片') {
|
||||||
|
return {
|
||||||
|
dosage_amount: 50,
|
||||||
|
dosage_bag_count: 1,
|
||||||
|
need_decoction: false,
|
||||||
|
bags_per_dose: 1,
|
||||||
|
times_per_day: 3,
|
||||||
|
usage_days: 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prescriptionType === '浓缩水丸') {
|
||||||
|
return {
|
||||||
|
dosage_amount: 5,
|
||||||
|
dosage_bag_count: 1,
|
||||||
|
need_decoction: false,
|
||||||
|
bags_per_dose: 1,
|
||||||
|
times_per_day: 3,
|
||||||
|
usage_days: 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dosage_amount: 1,
|
||||||
|
dosage_bag_count: 1,
|
||||||
|
need_decoction: false,
|
||||||
|
bags_per_dose: 1,
|
||||||
|
times_per_day: 3,
|
||||||
|
usage_days: 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
|
||||||
|
const base = defaultSlipAuxUsage(prescriptionType)
|
||||||
|
if (!raw || typeof raw !== 'object') return { ...base }
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
dosage_amount:
|
||||||
|
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
|
||||||
|
? Number(o.dosage_amount)
|
||||||
|
: base.dosage_amount,
|
||||||
|
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
|
||||||
|
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
|
||||||
|
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
|
||||||
|
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
|
||||||
|
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula) */
|
||||||
|
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
|
||||||
|
if (!rx) return false
|
||||||
|
const herbs = rx.herbs
|
||||||
|
if (Array.isArray(herbs)) {
|
||||||
|
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
|
||||||
|
}
|
||||||
|
return Number(rx.has_aux_formula) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
|
||||||
|
export const LOGISTICS_URGENT_KEYWORDS = [
|
||||||
|
'拒收',
|
||||||
|
'拒签',
|
||||||
|
'退件',
|
||||||
|
'客户拒收',
|
||||||
|
'拦截退回',
|
||||||
|
'退回发件',
|
||||||
|
'派件退回',
|
||||||
|
'无人签收',
|
||||||
|
'退回快件'
|
||||||
|
]
|
||||||
|
|
||||||
|
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
|
||||||
|
const t = String(s ?? '')
|
||||||
|
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logisticsTraceLineUrgent(context: unknown): boolean {
|
||||||
|
return logisticsStringHasUrgentKeyword(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
|
||||||
|
export function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
|
||||||
|
show: boolean
|
||||||
|
keywords: string[]
|
||||||
|
lines: string[]
|
||||||
|
} {
|
||||||
|
if (!p) {
|
||||||
|
return { show: false, keywords: [], lines: [] }
|
||||||
|
}
|
||||||
|
const traces = Array.isArray(p.traces)
|
||||||
|
? (p.traces as Array<{ time?: string; context?: string }>)
|
||||||
|
: []
|
||||||
|
const chunks: string[] = []
|
||||||
|
if (p.state_text) chunks.push(String(p.state_text))
|
||||||
|
if (p.hint) chunks.push(String(p.hint))
|
||||||
|
for (const row of traces) {
|
||||||
|
if (row.context) chunks.push(String(row.context))
|
||||||
|
}
|
||||||
|
const joined = chunks.join('\n')
|
||||||
|
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
|
||||||
|
if (!keywords.length) {
|
||||||
|
return { show: false, keywords: [], lines: [] }
|
||||||
|
}
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const row of traces) {
|
||||||
|
const ctx = String(row.context || '')
|
||||||
|
if (logisticsStringHasUrgentKeyword(ctx)) {
|
||||||
|
lines.push(ctx)
|
||||||
|
if (lines.length >= 5) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
|
||||||
|
lines.push(`最新状态:${p.state_text}`)
|
||||||
|
}
|
||||||
|
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
|
||||||
|
lines.push(String(p.hint))
|
||||||
|
}
|
||||||
|
return { show: true, keywords, lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
|
||||||
|
export function parseLogisticsTracePayload(
|
||||||
|
res: unknown,
|
||||||
|
expectedTrackingNumber: string,
|
||||||
|
expectedOrderId: number
|
||||||
|
): Record<string, any> | null {
|
||||||
|
const raw = (res as { data?: unknown })?.data ?? res
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const payload = raw as Record<string, any>
|
||||||
|
const respNum = String(payload.tracking_number || '').trim()
|
||||||
|
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||||
|
const respOrderId = Number(payload.order_id)
|
||||||
|
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
|
||||||
|
if (!row?.id) return false
|
||||||
|
const fs = Number(row.fulfillment_status)
|
||||||
|
return fs !== 3 && fs !== 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
|
||||||
|
export function formatDietaryTaboo(raw: unknown): string {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('、')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务套餐 dict:server_order */
|
||||||
|
export type ServicePackageOption = { name: string; value: string; status?: number }
|
||||||
|
|
||||||
|
export function normalizeServicePackageValue(v: unknown): string {
|
||||||
|
return String(v ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
|
||||||
|
export function parseServicePackageValues(raw: unknown): string[] {
|
||||||
|
if (raw == null || raw === '') return []
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map(normalizeServicePackageValue).filter(Boolean)
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
return raw.split(',').map((v) => v.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
const one = normalizeServicePackageValue(raw)
|
||||||
|
return one ? [one] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
|
||||||
|
const sa = normalizeServicePackageValue(a)
|
||||||
|
const sb = normalizeServicePackageValue(b)
|
||||||
|
if (!sa || !sb) return false
|
||||||
|
if (sa === sb) return true
|
||||||
|
const na = Number(sa)
|
||||||
|
const nb = Number(sb)
|
||||||
|
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw
|
||||||
|
.map((item: any) => ({
|
||||||
|
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
|
||||||
|
value: normalizeServicePackageValue(item?.value),
|
||||||
|
status: Number(item?.status ?? 1)
|
||||||
|
}))
|
||||||
|
.filter((item) => item.value !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findServicePackageOption(
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
value: unknown
|
||||||
|
): ServicePackageOption | undefined {
|
||||||
|
const key = normalizeServicePackageValue(value)
|
||||||
|
if (!key) return undefined
|
||||||
|
return options.find((opt) => servicePackageValueEquals(opt.value, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示用:value → 字典 name,多选用「、」连接 */
|
||||||
|
export function formatServicePackageLabels(
|
||||||
|
value: unknown,
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
emptyText = '—'
|
||||||
|
): string {
|
||||||
|
const packages = parseServicePackageValues(value)
|
||||||
|
if (packages.length === 0) return emptyText
|
||||||
|
const names = packages.map((val) => {
|
||||||
|
const option = findServicePackageOption(options, val)
|
||||||
|
return option?.name || val
|
||||||
|
})
|
||||||
|
return names.join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
|
||||||
|
export function mergeServicePackageSelectOptions(
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
selected: unknown[]
|
||||||
|
): ServicePackageOption[] {
|
||||||
|
const known = new Set(options.map((o) => o.value))
|
||||||
|
const extras: ServicePackageOption[] = []
|
||||||
|
for (const raw of selected) {
|
||||||
|
const val = normalizeServicePackageValue(raw)
|
||||||
|
if (!val || known.has(val)) continue
|
||||||
|
const matched = findServicePackageOption(options, val)
|
||||||
|
extras.push(matched ?? { name: val, value: val, status: 0 })
|
||||||
|
known.add(val)
|
||||||
|
}
|
||||||
|
return extras.length ? [...options, ...extras] : options
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@
|
|||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item class="w-[200px]" label="处方类型">
|
||||||
|
<el-select v-model="formData.formula_type" placeholder="全部" clearable>
|
||||||
|
<el-option label="全部" :value="''" />
|
||||||
|
<el-option label="主方" value="主方" />
|
||||||
|
<el-option label="辅方" value="辅方" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item class="w-[200px]" label="是否公开">
|
<el-form-item class="w-[200px]" label="是否公开">
|
||||||
<el-select v-model="formData.is_public" placeholder="全部" clearable>
|
<el-select v-model="formData.is_public" placeholder="全部" clearable>
|
||||||
<el-option label="全部" :value="''" />
|
<el-option label="全部" :value="''" />
|
||||||
@@ -37,6 +44,13 @@
|
|||||||
<el-table :data="pager.lists" size="large">
|
<el-table :data="pager.lists" size="large">
|
||||||
<el-table-column label="ID" prop="id" min-width="60" />
|
<el-table-column label="ID" prop="id" min-width="60" />
|
||||||
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
|
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="处方类型" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
|
||||||
|
{{ row.formula_type || '主方' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="药材数量" min-width="100">
|
<el-table-column label="药材数量" min-width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.herbs?.length || 0 }}味
|
{{ row.herbs?.length || 0 }}味
|
||||||
@@ -57,6 +71,13 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="禁用修改" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.disable_edit ? 'danger' : 'info'">
|
||||||
|
{{ row.disable_edit ? '已禁用' : '可修改' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="创建人" prop="creator_name" min-width="100" />
|
<el-table-column label="创建人" prop="creator_name" min-width="100" />
|
||||||
<el-table-column label="创建时间" prop="create_time" min-width="160" />
|
<el-table-column label="创建时间" prop="create_time" min-width="160" />
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
@@ -114,6 +135,13 @@
|
|||||||
show-word-limit
|
show-word-limit
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="处方类型" prop="formula_type">
|
||||||
|
<el-radio-group v-model="editForm.formula_type">
|
||||||
|
<el-radio value="主方">主方</el-radio>
|
||||||
|
<el-radio value="辅方">辅方</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="药材配方" prop="herbs" required>
|
<el-form-item label="药材配方" prop="herbs" required>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@@ -172,6 +200,21 @@
|
|||||||
勾选后,所有医生都可以查看和使用此处方模板
|
勾选后,所有医生都可以查看和使用此处方模板
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="禁用修改" prop="disable_edit">
|
||||||
|
<div class="flex flex-col gap-1 leading-none">
|
||||||
|
<el-switch
|
||||||
|
v-model="editForm.disable_edit"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
active-text="开启"
|
||||||
|
inactive-text="关闭"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-400 leading-relaxed">
|
||||||
|
开启后,导入此模板到处方时将锁定整张处方的药材,不可增删改,只能再次导入覆盖
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template #footer v-if="editMode !== 'view'">
|
<template #footer v-if="editMode !== 'view'">
|
||||||
@@ -200,6 +243,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
|||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
prescription_name: '',
|
prescription_name: '',
|
||||||
|
formula_type: '',
|
||||||
is_public: ''
|
is_public: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,8 +256,10 @@ const formRef = ref<FormInstance>()
|
|||||||
const editForm = reactive({
|
const editForm = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
prescription_name: '',
|
prescription_name: '',
|
||||||
|
formula_type: '主方',
|
||||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||||
is_public: 0
|
is_public: 0,
|
||||||
|
disable_edit: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
@@ -267,8 +313,10 @@ const removeHerb = (index: number) => {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
editForm.id = 0
|
editForm.id = 0
|
||||||
editForm.prescription_name = ''
|
editForm.prescription_name = ''
|
||||||
|
editForm.formula_type = '主方'
|
||||||
editForm.herbs = []
|
editForm.herbs = []
|
||||||
editForm.is_public = 0
|
editForm.is_public = 0
|
||||||
|
editForm.disable_edit = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增处方
|
// 新增处方
|
||||||
@@ -283,8 +331,10 @@ const handleView = (row: any) => {
|
|||||||
editMode.value = 'view'
|
editMode.value = 'view'
|
||||||
editForm.id = row.id
|
editForm.id = row.id
|
||||||
editForm.prescription_name = row.prescription_name || ''
|
editForm.prescription_name = row.prescription_name || ''
|
||||||
|
editForm.formula_type = row.formula_type || '主方'
|
||||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||||
editForm.is_public = row.is_public ?? 0
|
editForm.is_public = row.is_public ?? 0
|
||||||
|
editForm.disable_edit = row.disable_edit ?? 0
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +343,10 @@ const handleEdit = (row: any) => {
|
|||||||
editMode.value = 'edit'
|
editMode.value = 'edit'
|
||||||
editForm.id = row.id
|
editForm.id = row.id
|
||||||
editForm.prescription_name = row.prescription_name || ''
|
editForm.prescription_name = row.prescription_name || ''
|
||||||
|
editForm.formula_type = row.formula_type || '主方'
|
||||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||||
editForm.is_public = row.is_public ?? 0
|
editForm.is_public = row.is_public ?? 0
|
||||||
|
editForm.disable_edit = row.disable_edit ?? 0
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||