Compare commits
239
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e654c774d1 | ||
|
|
79ae055bb6 | ||
|
|
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 |
+2
-4
@@ -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
|
||||||
|
|||||||
Vendored
+1
-3
@@ -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
|
||||||
|
|||||||
+13
-2
@@ -15,11 +15,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{
|
||||||
|
|||||||
@@ -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,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,737 @@
|
|||||||
|
<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 isDevMode = (): boolean => {
|
||||||
|
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||||
|
try {
|
||||||
|
return process.env.NODE_ENV !== 'production'
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = ref(isDevMode())
|
||||||
|
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>
|
||||||
+10
-4
@@ -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,
|
||||||
|
|||||||
@@ -52,12 +52,20 @@
|
|||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||||
"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.5",
|
||||||
|
"provider" : "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mp-alipay" : {
|
"mp-alipay" : {
|
||||||
"usingComponents" : true
|
"usingComponents" : true
|
||||||
|
|||||||
Generated
+10471
-1784
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-19
@@ -3,7 +3,9 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/index/index",
|
"path": "pages/index/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "甄养堂"
|
"navigationBarTitleText": "甄养堂",
|
||||||
|
"backgroundColor": "#F8F8F8",
|
||||||
|
"backgroundColorTop": "#F8F8F8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,7 +45,9 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/user/user",
|
"path": "pages/user/user",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "我的"
|
"navigationBarTitleText": "我的",
|
||||||
|
"backgroundColor": "#e8eaed",
|
||||||
|
"backgroundColorTop": "#e8eaed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,25 +109,124 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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.5",
|
||||||
|
"provider": "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/weekly",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "血糖管理",
|
||||||
|
"backgroundColor": "#f4fbf4",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"mp-weixin": {
|
||||||
|
"usingPlugins": {
|
||||||
|
"WechatSI": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"provider": "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/more",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "日常护理",
|
||||||
|
"backgroundColor": "#f4fbf4",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"mp-weixin": {
|
||||||
|
"usingPlugins": {
|
||||||
|
"WechatSI": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"provider": "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/game",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "糖分突袭",
|
||||||
|
"backgroundColor": "#c7d2fe",
|
||||||
|
"disableScroll": 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
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,121 @@
|
|||||||
|
<template>
|
||||||
|
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
|
||||||
|
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||||
|
<image
|
||||||
|
v-if="src && !imageFailed"
|
||||||
|
class="food-tile-icon__img"
|
||||||
|
:src="src"
|
||||||
|
mode="aspectFit"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { getFoodImgUrl } from '../data/gameFoodImg.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
food: { type: [Object, String], default: null },
|
||||||
|
size: { type: String, default: 'tile' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageFailed = ref(false)
|
||||||
|
|
||||||
|
const fallbackIcon = computed(() => {
|
||||||
|
if (typeof props.food === 'object' && props.food?.icon) return props.food.icon
|
||||||
|
return '🍽'
|
||||||
|
})
|
||||||
|
|
||||||
|
const src = computed(() => {
|
||||||
|
if (typeof props.food === 'object' && props.food?.img) return props.food.img
|
||||||
|
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => src.value,
|
||||||
|
() => {
|
||||||
|
imageFailed.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function onImageError() {
|
||||||
|
imageFailed.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.food-tile-icon {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-tile-icon--tile {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: calc(var(--tile-emoji-size, 48px) * 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-tile-icon--tile .food-tile-icon__emoji,
|
||||||
|
.food-tile-icon--tile .food-tile-icon__img {
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 75%;
|
||||||
|
height: 75%;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
|
||||||
|
.food-tile-icon--tile .food-tile-icon__img {
|
||||||
|
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-tile-icon--goal {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-tile-icon--tooltip {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-tile-icon__emoji {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-tile-icon--goal .food-tile-icon__emoji,
|
||||||
|
.food-tile-icon--tooltip .food-tile-icon__emoji {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
font-size: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food-tile-icon__img {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</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,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,188 @@
|
|||||||
|
/**
|
||||||
|
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
|
||||||
|
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
let fxSeq = 0
|
||||||
|
function nextFxId(prefix) {
|
||||||
|
fxSeq += 1
|
||||||
|
return `${prefix}-${fxSeq}-${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开心消消乐式糖果色板(彩虹爆裂用)
|
||||||
|
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
|
||||||
|
// 星屑字形
|
||||||
|
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
|
||||||
|
|
||||||
|
export function useGameFx() {
|
||||||
|
const floatingTexts = ref([])
|
||||||
|
const burstParticles = ref([])
|
||||||
|
const ripples = ref([])
|
||||||
|
const comboBanner = ref({ show: false, text: '', tier: 1 })
|
||||||
|
const screenShaking = ref(false)
|
||||||
|
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
|
||||||
|
|
||||||
|
let comboBannerTimer = null
|
||||||
|
let shakeTimer = null
|
||||||
|
let flashTimer = null
|
||||||
|
let flashFadeTimer = null
|
||||||
|
|
||||||
|
function spawnFloatingText(payload) {
|
||||||
|
const id = nextFxId('ft')
|
||||||
|
floatingTexts.value.push({ id, ...payload })
|
||||||
|
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
|
||||||
|
setTimeout(() => {
|
||||||
|
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
|
||||||
|
}, duration)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
|
||||||
|
* @param {number} cx,cy 爆裂中心(屏幕坐标)
|
||||||
|
* @param {string} color 基础颜色
|
||||||
|
* @param {number} count 粒子数量
|
||||||
|
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
|
||||||
|
*/
|
||||||
|
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
|
||||||
|
const {
|
||||||
|
star = true,
|
||||||
|
rainbow = false,
|
||||||
|
gravity = 64,
|
||||||
|
sizeMin = 8,
|
||||||
|
sizeMax = 16,
|
||||||
|
distMin = 36,
|
||||||
|
distMax = 110,
|
||||||
|
life = 850
|
||||||
|
} = opts
|
||||||
|
const dur = (life / 1000).toFixed(2)
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const id = nextFxId('p')
|
||||||
|
const isStar = star && Math.random() < 0.65
|
||||||
|
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
|
||||||
|
const angle = Math.random() * Math.PI * 2
|
||||||
|
const distance = Math.random() * (distMax - distMin) + distMin
|
||||||
|
const tx = Math.cos(angle) * distance
|
||||||
|
// 先上抛、再叠加重力下落,形成喷泉抛物线
|
||||||
|
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
|
||||||
|
const half = pSize / 2
|
||||||
|
const pColor = rainbow
|
||||||
|
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
|
||||||
|
: color
|
||||||
|
const rot = Math.random() * 720 - 360
|
||||||
|
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
|
||||||
|
|
||||||
|
const particle = {
|
||||||
|
id,
|
||||||
|
left: cx - half,
|
||||||
|
top: cy - half,
|
||||||
|
size: pSize,
|
||||||
|
color: pColor,
|
||||||
|
char: glyph,
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'scale(0.4) rotate(0deg)',
|
||||||
|
transition: 'none'
|
||||||
|
}
|
||||||
|
burstParticles.value.push(particle)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
particle.transition =
|
||||||
|
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
|
||||||
|
particle.left = cx - half + tx
|
||||||
|
particle.top = cy - half + ty
|
||||||
|
particle.opacity = 0
|
||||||
|
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
|
||||||
|
}, 16)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
|
||||||
|
}, life + 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
|
||||||
|
* @param {object} opts { thickness, life }
|
||||||
|
*/
|
||||||
|
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
|
||||||
|
const id = nextFxId('rp')
|
||||||
|
const size = 36 + tier * 18
|
||||||
|
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
|
||||||
|
ripples.value.push({
|
||||||
|
id,
|
||||||
|
left: cx,
|
||||||
|
top: cy,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
thickness,
|
||||||
|
opacity: 0.95
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
ripples.value = ripples.value.filter((r) => r.id !== id)
|
||||||
|
}, life)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
|
||||||
|
*/
|
||||||
|
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
|
||||||
|
if (flashFadeTimer) clearTimeout(flashFadeTimer)
|
||||||
|
if (flashTimer) clearTimeout(flashTimer)
|
||||||
|
flash.value = { active: true, color, opacity: intensity }
|
||||||
|
flashFadeTimer = setTimeout(() => {
|
||||||
|
flash.value = { ...flash.value, opacity: 0 }
|
||||||
|
}, 24)
|
||||||
|
flashTimer = setTimeout(() => {
|
||||||
|
flash.value = { active: false, color, opacity: 0 }
|
||||||
|
}, life)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showComboBanner(chain, points) {
|
||||||
|
if (chain <= 1) return
|
||||||
|
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
|
||||||
|
comboBanner.value = {
|
||||||
|
show: true,
|
||||||
|
text: `连消 ×${chain} +${points}`,
|
||||||
|
tier
|
||||||
|
}
|
||||||
|
if (comboBannerTimer) clearTimeout(comboBannerTimer)
|
||||||
|
comboBannerTimer = setTimeout(() => {
|
||||||
|
comboBanner.value = { ...comboBanner.value, show: false }
|
||||||
|
}, 1100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerShake(intensity = 1) {
|
||||||
|
screenShaking.value = true
|
||||||
|
if (shakeTimer) clearTimeout(shakeTimer)
|
||||||
|
shakeTimer = setTimeout(() => {
|
||||||
|
screenShaking.value = false
|
||||||
|
}, intensity >= 2 ? 420 : 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
floatingTexts.value = []
|
||||||
|
burstParticles.value = []
|
||||||
|
ripples.value = []
|
||||||
|
comboBanner.value = { show: false, text: '', tier: 1 }
|
||||||
|
screenShaking.value = false
|
||||||
|
flash.value = { active: false, color: '#ffffff', opacity: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
floatingTexts,
|
||||||
|
burstParticles,
|
||||||
|
ripples,
|
||||||
|
comboBanner,
|
||||||
|
screenShaking,
|
||||||
|
flash,
|
||||||
|
spawnFloatingText,
|
||||||
|
spawnBurst,
|
||||||
|
spawnRipple,
|
||||||
|
showComboBanner,
|
||||||
|
triggerShake,
|
||||||
|
triggerFlash,
|
||||||
|
clearAll
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 游戏进度持久化:关卡、星级、连胜、每日打卡
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { calcLevelStars } from '../data/gameLevels.js'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'tongji_game_progress_v1'
|
||||||
|
|
||||||
|
const DEFAULT = {
|
||||||
|
currentLevel: 1,
|
||||||
|
maxUnlocked: 1,
|
||||||
|
stars: {},
|
||||||
|
winStreak: 0,
|
||||||
|
dailyStreak: 0,
|
||||||
|
lastPlayDate: '',
|
||||||
|
totalWins: 0,
|
||||||
|
bestCombo: 0,
|
||||||
|
totalStars: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr() {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumStars(starsMap) {
|
||||||
|
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRaw() {
|
||||||
|
try {
|
||||||
|
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||||
|
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
|
||||||
|
} catch (_) {}
|
||||||
|
return { ...DEFAULT }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRaw(data) {
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(STORAGE_KEY, data)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameProgress() {
|
||||||
|
const progress = ref(loadRaw())
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
progress.value.totalStars = sumStars(progress.value.stars)
|
||||||
|
saveRaw(progress.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入游戏时更新每日打卡 */
|
||||||
|
function touchDailyPlay() {
|
||||||
|
const today = todayStr()
|
||||||
|
const last = progress.value.lastPlayDate
|
||||||
|
if (last === today) return progress.value.dailyStreak
|
||||||
|
|
||||||
|
const yesterday = new Date()
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
if (last === yStr) {
|
||||||
|
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
|
||||||
|
} else {
|
||||||
|
progress.value.dailyStreak = 1
|
||||||
|
}
|
||||||
|
progress.value.lastPlayDate = today
|
||||||
|
persist()
|
||||||
|
return progress.value.dailyStreak
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStars(levelId) {
|
||||||
|
return progress.value.stars[String(levelId)] || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
|
||||||
|
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
|
||||||
|
const key = String(levelId)
|
||||||
|
const prev = progress.value.stars[key] || 0
|
||||||
|
if (stars > prev) progress.value.stars[key] = stars
|
||||||
|
|
||||||
|
progress.value.winStreak = (progress.value.winStreak || 0) + 1
|
||||||
|
progress.value.totalWins = (progress.value.totalWins || 0) + 1
|
||||||
|
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
|
||||||
|
|
||||||
|
const next = levelId + 1
|
||||||
|
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
|
||||||
|
progress.value.currentLevel = next
|
||||||
|
persist()
|
||||||
|
return { stars, newBest: stars > prev }
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordLoss() {
|
||||||
|
progress.value.winStreak = 0
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrentLevel(levelId) {
|
||||||
|
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
progress,
|
||||||
|
touchDailyPlay,
|
||||||
|
getStars,
|
||||||
|
recordWin,
|
||||||
|
recordLoss,
|
||||||
|
setCurrentLevel,
|
||||||
|
persist
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* 糖分突袭 · 游戏音效(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 = {
|
||||||
|
// —— 交互 ——
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.volume
|
||||||
|
try {
|
||||||
|
ctx.playbackRate = this.rate
|
||||||
|
} catch (_) {}
|
||||||
|
this.list.push(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warmUp() {
|
||||||
|
if (this.warmedUp) return
|
||||||
|
if (!this.list.length) this.create()
|
||||||
|
this.list.forEach((ctx) => {
|
||||||
|
try {
|
||||||
|
ctx.volume = 0
|
||||||
|
ctx.play()
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
ctx.stop()
|
||||||
|
ctx.volume = this.volume
|
||||||
|
} catch (_) {}
|
||||||
|
}, 60)
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
this.warmedUp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
play(scale = 1, rateMul = 1) {
|
||||||
|
if (!this.list.length) 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()
|
||||||
|
Object.keys(SFX).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,413 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const MIN_HOLD_MS = 280
|
||||||
|
|
||||||
|
// #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
|
||||||
|
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
|
||||||
|
try {
|
||||||
|
wxRecordManager.stop()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
if (h5Recognition && sttListening.value) {
|
||||||
|
try {
|
||||||
|
h5Recognition.stop()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
startRequested = 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
|
||||||
|
sharedRecording = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 松手结束:手指抬起 */
|
||||||
|
function endHoldSpeech() {
|
||||||
|
let recording = false
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
recording = sharedRecording && activeCtl === controller
|
||||||
|
// #endif
|
||||||
|
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sttHolding.value = false
|
||||||
|
// 松手后不再补开排队的录音
|
||||||
|
pendingStartSession = 0
|
||||||
|
if (sttListening.value || startRequested || recording) {
|
||||||
|
stopListening()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holdSessionId += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
|
||||||
|
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
|
||||||
|
const controller = {
|
||||||
|
handleStart() {
|
||||||
|
if (!sttHolding.value) {
|
||||||
|
try {
|
||||||
|
wxRecordManager.stop()
|
||||||
|
} catch (e) {}
|
||||||
|
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) || ''
|
||||||
|
// 上一段未停止就再次 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
|
||||||
|
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
||||||
|
if (typeof onError === 'function') {
|
||||||
|
const handled = onError(msg || '')
|
||||||
|
if (handled === true) return
|
||||||
|
}
|
||||||
|
notify(msg || '语音识别失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,654 @@
|
|||||||
|
/**
|
||||||
|
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
|
||||||
|
*/
|
||||||
|
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||||
|
|
||||||
|
function gid(key, suffix) {
|
||||||
|
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(key, body) {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shadow(key) {
|
||||||
|
const id = gid(key, 'sh')
|
||||||
|
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shine(x, y, rx, ry) {
|
||||||
|
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 圆形水果 */
|
||||||
|
function artRound(key, base, dark, light, extra = '') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
|
||||||
|
<stop offset="0%" stop-color="${light}"/>
|
||||||
|
<stop offset="55%" stop-color="${base}"/>
|
||||||
|
<stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</radialGradient>
|
||||||
|
${shadow(key)}
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
|
||||||
|
${shine(24, 28, 7, 5)}
|
||||||
|
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
|
||||||
|
${extra}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 叶菜 */
|
||||||
|
function artLeafy(key, light, mid, dark) {
|
||||||
|
const g1 = gid(key, 'g1')
|
||||||
|
const g2 = gid(key, 'g2')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>
|
||||||
|
${shadow(key)}
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
|
||||||
|
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
|
||||||
|
${shine(26, 24, 6, 8)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 西兰花 */
|
||||||
|
function artBroccoli(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
|
||||||
|
</radialGradient>
|
||||||
|
${shadow(key)}
|
||||||
|
</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
|
||||||
|
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
|
||||||
|
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
|
||||||
|
${shine(28, 20, 5, 4)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 长条(黄瓜/香蕉/玉米) */
|
||||||
|
function artLong(key, base, dark, light, type = 'cucumber') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
if (type === 'banana') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
|
||||||
|
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
|
||||||
|
${shine(30, 26, 5, 3)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
if (type === 'corn') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
|
||||||
|
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
|
||||||
|
Array.from({ length: 4 }, (_, c) =>
|
||||||
|
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
|
||||||
|
).join('')
|
||||||
|
).join('')}</g>
|
||||||
|
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
${shine(26, 26, 5, 8)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
|
||||||
|
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 茄子 */
|
||||||
|
function artEggplant(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
|
||||||
|
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
|
||||||
|
${shine(26, 32, 5, 7)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 番茄 */
|
||||||
|
function artTomato(key) {
|
||||||
|
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
|
||||||
|
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 甜椒 */
|
||||||
|
function artPepper(key, color, dark) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
|
||||||
|
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
|
||||||
|
${shine(28, 26, 5, 6)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 洋葱 */
|
||||||
|
function artOnion(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
|
||||||
|
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
|
||||||
|
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
|
||||||
|
${shine(24, 30, 6, 5)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 葡萄串 */
|
||||||
|
function artGrapes(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
|
||||||
|
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||||
|
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||||
|
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||||
|
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||||
|
${shine(28, 24, 3, 2)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 樱桃 */
|
||||||
|
function artCherry(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
|
||||||
|
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
|
||||||
|
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 草莓 */
|
||||||
|
function artStrawberry(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
|
||||||
|
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
|
||||||
|
${shine(26, 28, 4, 6)}
|
||||||
|
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
|
||||||
|
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
|
||||||
|
).join('')}</g>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 豆类 */
|
||||||
|
function artBeans(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
|
||||||
|
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
|
||||||
|
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 饮品杯 */
|
||||||
|
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
if (type === 'bottle') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
|
||||||
|
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
|
||||||
|
${shine(28, 30, 4, 10)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
if (type === 'wine') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
|
||||||
|
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
|
||||||
|
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
|
||||||
|
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
|
||||||
|
${shine(26, 32, 4, 8)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 蘑菇 */
|
||||||
|
function artMushroom(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
|
||||||
|
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
|
||||||
|
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
|
||||||
|
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
|
||||||
|
${shine(24, 24, 6, 4)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 芹菜 */
|
||||||
|
function artCelery(key) {
|
||||||
|
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 四季豆 */
|
||||||
|
function artGreenBean(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
|
||||||
|
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 饭碗 */
|
||||||
|
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
|
||||||
|
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
|
||||||
|
${shine(24, 34, 6, 3)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 薯类 */
|
||||||
|
function artRoot(key, base, dark, light) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
|
||||||
|
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
|
||||||
|
${shine(24, 30, 5, 6)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 肉类 */
|
||||||
|
function artMeat(key, base, dark, type = 'steak') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
if (type === 'chicken') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
|
||||||
|
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
|
||||||
|
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
|
||||||
|
${shine(26, 32, 5, 6)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
if (type === 'fish') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
|
||||||
|
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
|
||||||
|
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
|
||||||
|
${shine(28, 30, 6, 4)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
|
||||||
|
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
|
||||||
|
${shine(26, 28, 6, 4)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 虾/蟹 */
|
||||||
|
function artSeafood(key, type = 'shrimp') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
if (type === 'crab') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
|
||||||
|
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
|
||||||
|
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
|
||||||
|
${shine(30, 28, 5, 4)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 菠萝 */
|
||||||
|
function artPineapple(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
|
||||||
|
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
|
||||||
|
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
|
||||||
|
).join('')}</g>
|
||||||
|
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
|
||||||
|
${shine(26, 32, 5, 6)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 奇异果 */
|
||||||
|
function artKiwi(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
|
||||||
|
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
|
||||||
|
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
|
||||||
|
${shine(26, 28, 4, 3)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 蜜瓜 */
|
||||||
|
function artMelon(key) {
|
||||||
|
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 蜂蜜 */
|
||||||
|
function artHoney(key) {
|
||||||
|
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 面包类 */
|
||||||
|
function artBread(key, base, dark, type = 'loaf') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
if (type === 'donut') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
|
||||||
|
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
|
||||||
|
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||||
|
${shine(24, 28, 5, 4)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
if (type === 'mantou') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
|
||||||
|
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
|
||||||
|
${shine(24, 32, 6, 4)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
|
||||||
|
${shine(22, 28, 8, 5)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 面条 */
|
||||||
|
function artNoodle(key, soup = '#d7ccc8') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
|
||||||
|
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
|
||||||
|
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 南瓜 */
|
||||||
|
function artPumpkin(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||||
|
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
|
||||||
|
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||||
|
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
|
||||||
|
${shine(26, 28, 5, 6)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 薯条 */
|
||||||
|
function artFries(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
|
||||||
|
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 西瓜 */
|
||||||
|
function artWatermelon(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
|
||||||
|
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
|
||||||
|
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
|
||||||
|
${shine(24, 30, 4, 3)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 糖果 */
|
||||||
|
function artCandy(key, base, dark, type = 'lollipop') {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
if (type === 'wrap') {
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</linearGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
|
||||||
|
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
|
||||||
|
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
|
||||||
|
${shine(26, 24, 4, 3)}
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 糯米团 */
|
||||||
|
function artDango(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<circle cx="22" cy="36" r="8" fill="#fff"/>
|
||||||
|
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
|
||||||
|
<circle cx="42" cy="36" r="8" fill="#fff"/>
|
||||||
|
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 爆米花 */
|
||||||
|
function artPopcorn(key) {
|
||||||
|
const g = gid(key, 'g')
|
||||||
|
return wrap(key, `
|
||||||
|
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
|
||||||
|
</radialGradient>${shadow(key)}</defs>
|
||||||
|
<g filter="url(#${gid(key, 'sh')})">
|
||||||
|
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
|
||||||
|
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
|
||||||
|
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
|
||||||
|
</g>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOOD_ART = {
|
||||||
|
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
|
||||||
|
broccoli: () => artBroccoli('broccoli'),
|
||||||
|
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
|
||||||
|
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
|
||||||
|
eggplant: () => artEggplant('eggplant'),
|
||||||
|
tomato: () => artTomato('tomato'),
|
||||||
|
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
|
||||||
|
onion: () => artOnion('onion'),
|
||||||
|
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
|
||||||
|
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
|
||||||
|
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
|
||||||
|
cherry: () => artCherry('cherry'),
|
||||||
|
grape: () => artGrapes('grape'),
|
||||||
|
strawberry: () => artStrawberry('strawberry'),
|
||||||
|
soybean: () => artBeans('soybean'),
|
||||||
|
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
|
||||||
|
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
|
||||||
|
mushroom: () => artMushroom('mushroom'),
|
||||||
|
celery: () => artCelery('celery'),
|
||||||
|
greenBean: () => artGreenBean('greenBean'),
|
||||||
|
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
|
||||||
|
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
|
||||||
|
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
|
||||||
|
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
|
||||||
|
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
|
||||||
|
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
|
||||||
|
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
|
||||||
|
shrimp: () => artSeafood('shrimp', 'shrimp'),
|
||||||
|
crab: () => artSeafood('crab', 'crab'),
|
||||||
|
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
|
||||||
|
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
|
||||||
|
pineapple: () => artPineapple('pineapple'),
|
||||||
|
kiwi: () => artKiwi('kiwi'),
|
||||||
|
melon: () => artMelon('melon'),
|
||||||
|
honey: () => artHoney('honey'),
|
||||||
|
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
|
||||||
|
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
|
||||||
|
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
|
||||||
|
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
|
||||||
|
udon: () => artNoodle('udon', '#d9b88f'),
|
||||||
|
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
|
||||||
|
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
|
||||||
|
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
|
||||||
|
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
|
||||||
|
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
|
||||||
|
popcorn: () => artPopcorn('popcorn'),
|
||||||
|
ramen: () => artNoodle('ramen', '#e0a96d'),
|
||||||
|
pumpkin: () => artPumpkin('pumpkin'),
|
||||||
|
bakedPotato: () => artFries('bakedPotato'),
|
||||||
|
watermelon: () => artWatermelon('watermelon'),
|
||||||
|
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
|
||||||
|
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
|
||||||
|
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
|
||||||
|
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
|
||||||
|
stickyRice: () => artDango('stickyRice')
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgCache = {}
|
||||||
|
|
||||||
|
/** 获取食材插画 data URL(带缓存) */
|
||||||
|
export function getFoodImg(key) {
|
||||||
|
if (imgCache[key]) return imgCache[key]
|
||||||
|
const builder = FOOD_ART[key]
|
||||||
|
if (!builder) return ''
|
||||||
|
imgCache[key] = svgToDataUrl(builder())
|
||||||
|
return imgCache[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function warmUpFoodImgs(keys) {
|
||||||
|
keys.forEach((k) => getFoodImg(k))
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 食材图标 URL(Twemoji PNG,小程序 / H5 通用)
|
||||||
|
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
|
||||||
|
*/
|
||||||
|
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
|
||||||
|
|
||||||
|
/** emoji → Twemoji PNG 地址 */
|
||||||
|
export function emojiToTwemojiUrl(emoji) {
|
||||||
|
if (!emoji) return ''
|
||||||
|
const parts = []
|
||||||
|
for (let i = 0; i < emoji.length;) {
|
||||||
|
const cp = emoji.codePointAt(i)
|
||||||
|
if (!cp) break
|
||||||
|
parts.push(cp.toString(16))
|
||||||
|
i += cp > 0xffff ? 2 : 1
|
||||||
|
}
|
||||||
|
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgCache = {}
|
||||||
|
|
||||||
|
export function getFoodImgUrl(emoji) {
|
||||||
|
if (!emoji) return ''
|
||||||
|
if (imgCache[emoji]) return imgCache[emoji]
|
||||||
|
const url = emojiToTwemojiUrl(emoji)
|
||||||
|
imgCache[emoji] = url
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
|
||||||
|
export function warmUpFoodImgs(foods) {
|
||||||
|
const list = Array.isArray(foods) ? foods : []
|
||||||
|
list.forEach((f) => {
|
||||||
|
const icon = typeof f === 'string' ? f : f?.icon
|
||||||
|
// 优先预热自定义图片素材,缺省回退 emoji PNG
|
||||||
|
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
|
||||||
|
if (!url) return
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
try {
|
||||||
|
uni.getImageInfo({ src: url, fail: () => {} })
|
||||||
|
} catch (_) {}
|
||||||
|
// #endif
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = url
|
||||||
|
} catch (_) {}
|
||||||
|
// #endif
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 全量食材库:key 用于消除匹配,img 为 Twemoji PNG(小程序可用)
|
||||||
|
*/
|
||||||
|
import { getFoodImgUrl } from './gameFoodImg.js'
|
||||||
|
|
||||||
|
export const GI_LABEL = { low: '低血糖', mid: '中血糖', high: '高血糖' }
|
||||||
|
|
||||||
|
/** 若实际食用时的升糖幅度(教育提示用,正值=升糖) */
|
||||||
|
export function getEatVal(food) {
|
||||||
|
if (food.eatVal != null) return food.eatVal
|
||||||
|
if (food.gi === 'high') return Math.abs(food.val)
|
||||||
|
if (food.gi === 'mid') return Math.max(1, food.val)
|
||||||
|
return Math.max(1, Math.abs(food.val))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从餐盘消除该食物后的血糖变化(负值=控糖有益) */
|
||||||
|
export function getClearVal(food) {
|
||||||
|
if (food.clearVal != null) return food.clearVal
|
||||||
|
const eat = getEatVal(food)
|
||||||
|
if (food.gi === 'high') return -Math.max(8, Math.round(eat * 0.5))
|
||||||
|
if (food.gi === 'mid') return -1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击食材时的升糖说明文案 */
|
||||||
|
export function formatEatLabel(food) {
|
||||||
|
if (food.gi === 'low') return '升糖低'
|
||||||
|
return `若食用 +${getEatVal(food)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 含糖等级简称:低糖 / 中糖 / 高糖 */
|
||||||
|
export const GI_SHORT = { low: '低糖', mid: '中糖', high: '高糖' }
|
||||||
|
|
||||||
|
/** 含糖等级对应的提示主色(绿 / 橙 / 红) */
|
||||||
|
export const GI_COLOR = { low: '#16a34a', mid: '#ea580c', high: '#dc2626' }
|
||||||
|
|
||||||
|
/** 部分代表食物的专属科普文案(一句话,便于老人记忆) */
|
||||||
|
const FOOD_TIP_OVERRIDE = {
|
||||||
|
whiteRice: '精制主食,升糖快,建议小份',
|
||||||
|
stickyRice: '糯米黏软,升糖很快,要少吃',
|
||||||
|
whiteBread: '精制面食,升糖快',
|
||||||
|
mantou: '白面馒头,升糖快,配菜一起吃更稳',
|
||||||
|
youtiao: '油炸又高糖,少吃为好',
|
||||||
|
sugar: '纯糖,升糖最快,尽量不吃',
|
||||||
|
maltose: '糖分很高,升糖快',
|
||||||
|
soda: '含糖饮料,升糖很快,不建议喝',
|
||||||
|
orangeJuice: '果汁含糖高,吃整果更好',
|
||||||
|
watermelon: '很甜,升糖快,一次别吃太多',
|
||||||
|
donut: '又甜又油,升糖高',
|
||||||
|
banana: '偏甜水果,适量吃',
|
||||||
|
sweetPotato: '粗粮但有糖,可代替部分主食',
|
||||||
|
corn: '粗粮主食,适量吃',
|
||||||
|
honey: '本质是糖,别当健康品多吃',
|
||||||
|
broccoli: '新鲜蔬菜,升糖很慢,可多吃',
|
||||||
|
cucumber: '清爽蔬菜,几乎不升糖',
|
||||||
|
lettuce: '绿叶菜,升糖很低,可多吃',
|
||||||
|
celery: '高纤维蔬菜,升糖低',
|
||||||
|
apple: '带皮整果,升糖较慢',
|
||||||
|
milk: '低糖,含蛋白,适量喝好',
|
||||||
|
fish: '优质蛋白,升糖低',
|
||||||
|
beef: '蛋白为主,升糖低',
|
||||||
|
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
|
||||||
|
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
|
||||||
|
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
|
||||||
|
friedNoodles: '油多又是精面,升糖快',
|
||||||
|
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 含糖等级通用科普文案 */
|
||||||
|
const GI_TIP_DEFAULT = {
|
||||||
|
low: '升糖慢,相对放心,可常吃',
|
||||||
|
mid: '升糖中等,注意分量',
|
||||||
|
high: '含糖高、升糖快,要少吃'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击食物时展示的一句话科普(认知教育用) */
|
||||||
|
export function getFoodTip(food) {
|
||||||
|
if (!food) return ''
|
||||||
|
if (food.tip) return food.tip
|
||||||
|
if (FOOD_TIP_OVERRIDE[food.key]) return FOOD_TIP_OVERRIDE[food.key]
|
||||||
|
return GI_TIP_DEFAULT[food.gi] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自定义图片素材目录(腾讯云 COS,优先使用真实图片而非 emoji) */
|
||||||
|
const GAMES_IMG_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games'
|
||||||
|
|
||||||
|
const RAW_FOODS = [
|
||||||
|
// 低 GI
|
||||||
|
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
|
||||||
|
{ key: 'broccoli', icon: '🥦', name: '西兰花', gi: 'low', val: -3, color: '#4E8A3A' },
|
||||||
|
{ key: 'apple', icon: '🍎', name: '苹果', gi: 'low', val: -2, color: '#E53935' },
|
||||||
|
{ key: 'cucumber', icon: '🥒', name: '黄瓜', gi: 'low', val: -4, color: '#66A82F' },
|
||||||
|
{ key: 'eggplant', icon: '🍆', name: '茄子', gi: 'low', val: -3, color: '#7E57C2' },
|
||||||
|
{ key: 'tomato', icon: '🍅', name: '番茄', gi: 'low', val: -2, color: '#EF5350' },
|
||||||
|
{ key: 'pepper', icon: '🫑', name: '青椒', gi: 'low', val: -3, color: '#43A047' },
|
||||||
|
{ key: 'onion', icon: '🧅', name: '洋葱', gi: 'low', val: -2, color: '#CDA06B' },
|
||||||
|
{ key: 'pear', icon: '🍐', name: '雪梨', gi: 'low', val: -2, color: '#C0CA33' },
|
||||||
|
{ key: 'orange', icon: '🍊', name: '橙子', gi: 'low', val: -2, color: '#FB8C00' },
|
||||||
|
{ key: 'peach', icon: '🍑', name: '桃子', gi: 'low', val: -2, color: '#FF8A65' },
|
||||||
|
{ key: 'cherry', icon: '🍒', name: '樱桃', gi: 'low', val: -2, color: '#C2185B' },
|
||||||
|
{ key: 'grape', icon: '🍇', name: '葡萄', gi: 'low', val: -1, color: '#8E24AA' },
|
||||||
|
{ key: 'strawberry', icon: '🍓', name: '草莓', gi: 'low', val: -2, color: '#EC407A' },
|
||||||
|
{ key: 'soybean', icon: '🫘', name: '黄豆', gi: 'low', val: -3, color: '#C9A063' },
|
||||||
|
{ key: 'milk', icon: '🥛', name: '牛奶', gi: 'low', val: -2, color: '#B0BEC5' },
|
||||||
|
{ key: 'tea', icon: '🍵', name: '红茶', gi: 'low', val: -1, color: '#A1573B' },
|
||||||
|
{ key: 'mushroom', icon: '🍄', name: '香菇', gi: 'low', val: -3, color: '#8D6E63' },
|
||||||
|
{ key: 'celery', icon: '🌿', name: '芹菜', gi: 'low', val: -4, color: '#7CB342' },
|
||||||
|
{ key: 'greenBean', icon: '🫛', name: '四季豆', gi: 'low', val: -3, color: '#689F38' },
|
||||||
|
|
||||||
|
// 中 GI
|
||||||
|
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
||||||
|
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
|
||||||
|
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
|
||||||
|
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
|
||||||
|
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
|
||||||
|
{ key: 'beef', icon: '🥩', name: '牛肉', gi: 'mid', val: 3, color: '#C0504D' },
|
||||||
|
{ key: 'leanMeat', icon: '🍖', name: '瘦肉', gi: 'mid', val: 3, color: '#B5654D' },
|
||||||
|
{ key: 'shrimp', icon: '🦐', name: '虾仁', gi: 'mid', val: 2, color: '#FF7043' },
|
||||||
|
{ key: 'crab', icon: '🦀', name: '螃蟹', gi: 'mid', val: 2, color: '#F4623A' },
|
||||||
|
{ key: 'banana', icon: '🍌', name: '香蕉', gi: 'mid', val: 6, color: '#FDD835' },
|
||||||
|
{ key: 'mango', icon: '🥭', name: '芒果', gi: 'mid', val: 6, color: '#FFB300' },
|
||||||
|
{ key: 'pineapple', icon: '🍍', name: '菠萝', gi: 'mid', val: 6, color: '#FBC02D' },
|
||||||
|
{ key: 'kiwi', icon: '🥝', name: '奇异果', gi: 'mid', val: 4, color: '#9CCC65' },
|
||||||
|
{ key: 'melon', icon: '🍈', name: '哈密瓜', gi: 'mid', val: 6, color: '#AED581' },
|
||||||
|
{ key: 'honey', icon: '🍯', name: '蜂蜜', gi: 'mid', val: 6, color: '#F9A825' },
|
||||||
|
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
|
||||||
|
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
|
||||||
|
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
|
||||||
|
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
|
||||||
|
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
|
||||||
|
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
|
||||||
|
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
|
||||||
|
|
||||||
|
// 高 GI
|
||||||
|
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
|
||||||
|
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
|
||||||
|
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
|
||||||
|
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
|
||||||
|
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
|
||||||
|
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
|
||||||
|
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
|
||||||
|
{ key: 'pumpkin', icon: '🎃', name: '南瓜', gi: 'high', val: 15, color: '#EF6C00' },
|
||||||
|
{ key: 'bakedPotato', icon: '🍟', name: '焗薯', gi: 'high', val: 20, color: '#F2C14E' },
|
||||||
|
{ key: 'watermelon', icon: '🍉', name: '西瓜', gi: 'high', val: 16, color: '#F0506A' },
|
||||||
|
{ key: 'sugar', icon: '🍭', name: '砂糖', gi: 'high', val: 25, color: '#FF79B0' },
|
||||||
|
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
|
||||||
|
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
|
||||||
|
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
|
||||||
|
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
|
||||||
|
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
|
||||||
|
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
|
||||||
|
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||||
|
const eatVal = getEatVal(f)
|
||||||
|
const clearVal = getClearVal({ ...f, eatVal })
|
||||||
|
return {
|
||||||
|
id: i + 1,
|
||||||
|
...f,
|
||||||
|
eatVal,
|
||||||
|
clearVal,
|
||||||
|
giLabel: GI_LABEL[f.gi],
|
||||||
|
get img() {
|
||||||
|
// 优先使用自定义图片素材,缺省回退到 emoji PNG
|
||||||
|
return f.img || getFoodImgUrl(f.icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 按 key 查找食材(目标栏等用) */
|
||||||
|
export function getFoodByKey(key) {
|
||||||
|
return FOOD_LIBRARY.find((f) => f.key === key) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为棋盘 tile 使用的 plain 对象(img 预计算,避免 getter 在响应式里反复触发) */
|
||||||
|
export function cloneFoodType(food) {
|
||||||
|
return {
|
||||||
|
id: food.id,
|
||||||
|
key: food.key,
|
||||||
|
icon: food.icon,
|
||||||
|
name: food.name,
|
||||||
|
gi: food.gi,
|
||||||
|
giLabel: food.giLabel,
|
||||||
|
val: food.val,
|
||||||
|
color: food.color,
|
||||||
|
img: food.img || getFoodImgUrl(food.icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
|
||||||
|
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
|
||||||
|
* 第一关固定为这 5 种带真实图片素材的食物。
|
||||||
|
*/
|
||||||
|
const FIXED_LEVEL_FOODS = {
|
||||||
|
1: ['milkOats', 'grainMantou', 'riceNoodleSoup', 'friedNoodles', 'centuryEggCongee']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLevelConfig(levelId) {
|
||||||
|
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
|
||||||
|
const tier = Math.floor((lv - 1) / 5)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lv,
|
||||||
|
// 步数充裕:仅作展示,认全目标即可通关
|
||||||
|
moves: Math.max(40, 60 - tier * 2),
|
||||||
|
// 认识目标平缓增长,避免给老人压力
|
||||||
|
goalTargets: [6 + tier, 8 + tier],
|
||||||
|
scoreTarget: 2000 + lv * 300,
|
||||||
|
startGlucose: 50,
|
||||||
|
// 固定食材(可选):存在时本关只用这些食物
|
||||||
|
foods: FIXED_LEVEL_FOODS[lv] || null,
|
||||||
|
boosters: {
|
||||||
|
insulin: 3,
|
||||||
|
fiber: 1,
|
||||||
|
meal: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据本局表现计算 1~3 星:完成即得星,步数越省星越多(不依赖血糖) */
|
||||||
|
export function calcLevelStars({ movesLeft, levelConfig }) {
|
||||||
|
const totalMoves = levelConfig?.moves || 1
|
||||||
|
const ratio = totalMoves > 0 ? movesLeft / totalMoves : 0
|
||||||
|
let stars = 1
|
||||||
|
if (ratio >= 0.3) stars = 2
|
||||||
|
if (ratio >= 0.55) stars = 3
|
||||||
|
return stars
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,974 @@
|
|||||||
|
/**
|
||||||
|
* 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--in-card {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
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,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>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,32 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import uni from '@dcloudio/vite-plugin-uni'
|
||||||
|
|
||||||
|
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||||
|
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [uni()],
|
||||||
|
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/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ 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 +180,43 @@ 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 医助个人业绩概览 */
|
||||||
|
export function assistantPerformanceOverview(params: {
|
||||||
|
time_type?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||||
|
}
|
||||||
|
|||||||
+38
-2
@@ -319,6 +319,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 +403,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 })
|
||||||
}
|
}
|
||||||
@@ -452,20 +467,36 @@ 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
|
||||||
}) {
|
}) {
|
||||||
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 })
|
||||||
@@ -490,6 +521,11 @@ export function prescriptionOrderUpdateAmount(params: { id: number; amount: numb
|
|||||||
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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,303 @@
|
|||||||
|
<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">
|
||||||
|
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||||
|
</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">
|
||||||
|
<!-- <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([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
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
|
||||||
|
} 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 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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* 处方业务订单——纯展示/格式化工具
|
||||||
|
*
|
||||||
|
* 唯一数据源: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: '处方患者信息',
|
||||||
|
update_amount: '修改订单金额',
|
||||||
|
complete: '完成订单',
|
||||||
|
refund: '退款'
|
||||||
|
}
|
||||||
|
return m[act] || act
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付单来源/方式:企微对外收款、付呗等创建链路 + 支付方式回退 */
|
||||||
|
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 '付呗'
|
||||||
|
const paymentMethod = String(row?.payment_method || '')
|
||||||
|
const methodMap: Record<string, string> = {
|
||||||
|
alipay: '支付宝',
|
||||||
|
wechat: '微信',
|
||||||
|
wechat_work: '企业微信',
|
||||||
|
fubei: '付呗',
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -216,6 +216,15 @@
|
|||||||
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
|
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
|
||||||
查看
|
查看
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="Number(row.void_status) !== 1"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
v-perms="['tcm.prescription/patchPatient']"
|
||||||
|
@click="openPatchPatientDialog(row)"
|
||||||
|
>
|
||||||
|
改姓名手机
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1"
|
v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1"
|
||||||
type="success"
|
type="success"
|
||||||
@@ -398,20 +407,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rx-herbs">
|
<div class="rx-herbs">
|
||||||
<div
|
<template v-if="slipMainHerbs.length">
|
||||||
v-for="(h, i) in slipHerbsList"
|
<div class="rx-herb-section-label">主方</div>
|
||||||
:key="i"
|
<div
|
||||||
class="rx-herb-cell"
|
v-for="(h, i) in slipMainHerbs"
|
||||||
>
|
:key="'main-' + i"
|
||||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
class="rx-herb-cell"
|
||||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
>
|
||||||
</div>
|
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||||
|
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="slipAuxHerbs.length">
|
||||||
|
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||||
|
<div
|
||||||
|
v-for="(h, i) in slipAuxHerbs"
|
||||||
|
:key="'aux-' + i"
|
||||||
|
class="rx-herb-cell"
|
||||||
|
>
|
||||||
|
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||||
|
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
|
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
|
||||||
<div class="rx-text">
|
<div class="rx-text">
|
||||||
<p>服法:{{ rxUsageText }}</p>
|
<p>主方服法:{{ rxUsageText }}</p>
|
||||||
|
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
||||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||||
@@ -434,7 +458,9 @@
|
|||||||
alt="医师签名"
|
alt="医师签名"
|
||||||
class="rx-bot-sign-img"
|
class="rx-bot-sign-img"
|
||||||
/>
|
/>
|
||||||
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
|
<div v-if="!slipView.doctor_signature" class="rx-bot-doctor-name">
|
||||||
|
{{ slipView.doctor_name || '—' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
<div class="rx-bot-cell rx-bot-meta">
|
||||||
@@ -640,53 +666,95 @@
|
|||||||
<el-form-item label="药材配方" prop="herbs" required>
|
<el-form-item label="药材配方" prop="herbs" required>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<el-button type="primary" size="small" @click="addHerb">
|
<el-button
|
||||||
添加药材
|
type="success"
|
||||||
</el-button>
|
size="small"
|
||||||
<el-button type="success" size="small" @click="openLibraryDialog">
|
v-perms="['cf.prescription/add', 'cf.prescription/edit', 'tcm.prescriptionLibrary/lists']"
|
||||||
|
@click="openLibraryDialog"
|
||||||
|
>
|
||||||
从处方库导入
|
从处方库导入
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
|
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
|
||||||
导入药方
|
导入药方
|
||||||
</el-button>
|
</el-button>
|
||||||
<span class="text-xs text-gray-500 ml-2 align-middle">
|
<span class="text-xs text-gray-500">
|
||||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入
|
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="editForm.herbs" class="mt-2" border>
|
<div v-if="herbsLocked" class="flex items-center gap-2 flex-wrap mt-2 text-xs text-[var(--el-color-danger)]">
|
||||||
<el-table-column label="序号" type="index" width="60" />
|
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
|
||||||
<el-table-column label="药材名称" min-width="220">
|
含「禁用修改」处方库模板,药材已锁定,不可增删改,如需调整请重新「从处方库导入」覆盖
|
||||||
<template #default="{ row }">
|
</div>
|
||||||
<MedicineNameSelect v-model="row.name" class="w-full" />
|
|
||||||
</template>
|
<div class="herb-formula-block mt-3">
|
||||||
</el-table-column>
|
<div class="herb-formula-block__head">
|
||||||
<el-table-column label="剂量(克)" min-width="120">
|
<el-tag type="primary" size="small">主方</el-tag>
|
||||||
<template #default="{ row, $index }">
|
<el-button type="primary" size="small" :disabled="herbsLocked" @click="addHerb('主方')">添加主方药材</el-button>
|
||||||
<el-input-number
|
</div>
|
||||||
v-model="row.dosage"
|
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
|
||||||
:min="0"
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
:precision="1"
|
<el-table-column label="药材名称" min-width="220">
|
||||||
:step="0.5"
|
<template #default="{ row }">
|
||||||
placeholder="剂量"
|
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||||
class="w-full"
|
</template>
|
||||||
/>
|
</el-table-column>
|
||||||
</template>
|
<el-table-column label="剂量(克)" min-width="120">
|
||||||
</el-table-column>
|
<template #default="{ row }">
|
||||||
<el-table-column label="操作" width="80">
|
<el-input-number
|
||||||
<template #default="{ $index }">
|
v-model="editForm.herbs[row.index].dosage"
|
||||||
<el-button
|
:min="0"
|
||||||
type="danger"
|
:precision="1"
|
||||||
link
|
:step="0.5"
|
||||||
@click="removeHerb($index)"
|
:disabled="herbsLocked"
|
||||||
>
|
placeholder="剂量"
|
||||||
删除
|
class="w-full"
|
||||||
</el-button>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
</el-table>
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||||
|
<span v-else class="text-gray-400 text-xs">已锁定</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="herb-formula-block mt-4">
|
||||||
|
<div class="herb-formula-block__head">
|
||||||
|
<el-tag type="warning" size="small">辅方</el-tag>
|
||||||
|
<el-button type="primary" size="small" plain :disabled="herbsLocked" @click="addHerb('辅方')">
|
||||||
|
添加辅方药材
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="auxHerbRows" class="mt-2" border empty-text="暂无辅方药材">
|
||||||
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
|
<el-table-column label="药材名称" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="剂量(克)" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.herbs[row.index].dosage"
|
||||||
|
:min="0"
|
||||||
|
:precision="1"
|
||||||
|
:step="0.5"
|
||||||
|
:disabled="herbsLocked"
|
||||||
|
placeholder="剂量"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||||
|
<span v-else class="text-gray-400 text-xs">已锁定</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="editMode === 'edit'"
|
v-if="editMode === 'edit'"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -769,8 +837,10 @@
|
|||||||
<el-option label="汤剂" value="汤剂" />
|
<el-option label="汤剂" value="汤剂" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item> </el-col>
|
</el-form-item> </el-col>
|
||||||
|
</el-row>
|
||||||
<!-- 用量字段 -->
|
|
||||||
|
<div class="usage-formula-title usage-formula-title--main">主方用法</div>
|
||||||
|
<el-row :gutter="20">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="用量" prop="dosage_amount">
|
<el-form-item label="用量" prop="dosage_amount">
|
||||||
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
||||||
@@ -872,6 +942,97 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<template v-if="auxHerbRows.length > 0">
|
||||||
|
<div class="usage-formula-title usage-formula-title--aux">辅方用法</div>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="用量">
|
||||||
|
<el-select
|
||||||
|
v-if="editForm.prescription_type === '浓缩水丸'"
|
||||||
|
v-model="editForm.aux_usage.dosage_amount"
|
||||||
|
placeholder="请选择用量"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-option v-for="n in 10" :key="n" :label="n + 'g'" :value="n" />
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-else-if="editForm.prescription_type === '饮片'"
|
||||||
|
v-model="editForm.aux_usage.dosage_amount"
|
||||||
|
placeholder="请选择用量"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-option label="50ml" :value="50" />
|
||||||
|
<el-option label="100ml" :value="100" />
|
||||||
|
<el-option label="120ml" :value="120" />
|
||||||
|
<el-option label="150ml" :value="150" />
|
||||||
|
<el-option label="180ml" :value="180" />
|
||||||
|
<el-option label="200ml" :value="200" />
|
||||||
|
<el-option label="250ml" :value="250" />
|
||||||
|
</el-select>
|
||||||
|
<el-input-number
|
||||||
|
v-else
|
||||||
|
v-model="editForm.aux_usage.dosage_amount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
|
||||||
|
<el-form-item label=" " label-width="12px">
|
||||||
|
<el-select
|
||||||
|
v-model="editForm.aux_usage.dosage_bag_count"
|
||||||
|
placeholder="请选择袋数"
|
||||||
|
class="w-[120px]"
|
||||||
|
>
|
||||||
|
<el-option label="1袋" :value="1" />
|
||||||
|
<el-option label="2袋" :value="2" />
|
||||||
|
<el-option label="3袋" :value="3" />
|
||||||
|
<el-option label="4袋" :value="4" />
|
||||||
|
<el-option label="5袋" :value="5" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||||
|
<el-form-item label="是否代煎">
|
||||||
|
<el-radio-group v-model="editForm.aux_usage.need_decoction">
|
||||||
|
<el-radio :label="true">代煎</el-radio>
|
||||||
|
<el-radio :label="false">不代煎</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||||
|
<el-form-item label="每贴出包数">
|
||||||
|
<el-select v-model="editForm.aux_usage.bags_per_dose" placeholder="请选择" class="w-full">
|
||||||
|
<el-option v-for="n in 9" :key="n" :label="n + '包'" :value="n" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="每天几次">
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.aux_usage.times_per_day"
|
||||||
|
:min="1"
|
||||||
|
:max="6"
|
||||||
|
class="!w-full"
|
||||||
|
placeholder="每天服用次数"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="服用天数">
|
||||||
|
<el-input-number
|
||||||
|
v-model="editForm.aux_usage.usage_days"
|
||||||
|
:min="1"
|
||||||
|
:max="365"
|
||||||
|
placeholder="服用天数"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
<el-form-item label="用法" prop="usage_instruction">
|
<el-form-item label="用法" prop="usage_instruction">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -980,6 +1141,48 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 修正处方笺姓名 / 手机号(zyt_tcm_prescription,写入订单日志) -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="patchPatientVisible"
|
||||||
|
title="修正姓名、性别与手机号"
|
||||||
|
width="440px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="resetPatchPatientForm"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="patchPatientFormRef"
|
||||||
|
:model="patchPatientForm"
|
||||||
|
:rules="patchPatientRules"
|
||||||
|
label-width="88px"
|
||||||
|
>
|
||||||
|
<el-form-item label="处方编号">
|
||||||
|
<span class="text-gray-700">#{{ patchPatientForm.id || '—' }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="患者姓名" prop="patient_name">
|
||||||
|
<el-input v-model="patchPatientForm.patient_name" maxlength="50" show-word-limit placeholder="处方笺展示姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="性别" prop="gender">
|
||||||
|
<el-radio-group v-model="patchPatientForm.gender">
|
||||||
|
<el-radio :label="1">男</el-radio>
|
||||||
|
<el-radio :label="0">女</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input v-model="patchPatientForm.phone" maxlength="20" placeholder="处方笺展示手机号" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<p class="text-xs text-gray-500 -mt-2 mb-2">
|
||||||
|
仅更新处方表 patient_name、gender、phone,不改变审核状态;记录写入业务订单操作日志(有关联订单时可在此订单日志中查看)。
|
||||||
|
</p>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="patchPatientVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="patchPatientSubmitLoading" @click="submitPatchPatient">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
|
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="createOrderVisible"
|
v-model="createOrderVisible"
|
||||||
@@ -1314,16 +1517,17 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 从处方库导入(与诊间 tcm-prescription 一致,按当前处方开方医师 creator_id 筛选) -->
|
<!-- 从处方库导入:当前开方医师的全部模板 + 所有人可见的公共模板 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showLibraryDialog"
|
v-model="showLibraryDialog"
|
||||||
title="从处方库导入"
|
title="从处方库导入"
|
||||||
width="800px"
|
width="800px"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4 flex gap-3">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="librarySearchName"
|
v-model="librarySearchName"
|
||||||
|
class="flex-1"
|
||||||
placeholder="请输入处方名称搜索"
|
placeholder="请输入处方名称搜索"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="searchLibrary"
|
@keyup.enter="searchLibrary"
|
||||||
@@ -1333,6 +1537,24 @@
|
|||||||
<el-button :icon="Search" @click="searchLibrary" />
|
<el-button :icon="Search" @click="searchLibrary" />
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
<el-select
|
||||||
|
v-model="librarySearchFormulaType"
|
||||||
|
placeholder="处方类型"
|
||||||
|
clearable
|
||||||
|
class="w-[140px]"
|
||||||
|
@change="searchLibrary"
|
||||||
|
>
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="主方" value="主方" />
|
||||||
|
<el-option label="辅方" value="辅方" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
|
||||||
|
<el-radio-group v-model="libraryImportMode">
|
||||||
|
<el-radio label="replace">覆盖现有药材</el-radio>
|
||||||
|
<el-radio label="append">追加到末尾</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
@@ -1343,6 +1565,13 @@
|
|||||||
@row-click="handleSelectLibraryRow"
|
@row-click="handleSelectLibraryRow"
|
||||||
>
|
>
|
||||||
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
|
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column label="处方类型" width="90">
|
||||||
|
<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="药材数量" width="100">
|
<el-table-column label="药材数量" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.herbs?.length || 0 }}味
|
{{ row.herbs?.length || 0 }}味
|
||||||
@@ -1356,6 +1585,13 @@
|
|||||||
<span v-else class="text-gray-400">暂无药材</span>
|
<span v-else class="text-gray-400">暂无药材</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="是否公开" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_public ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.is_public ? '所有人可见' : '仅自己可见' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="创建人" prop="creator_name" width="100" />
|
<el-table-column label="创建人" prop="creator_name" width="100" />
|
||||||
<el-table-column label="操作" width="100" fixed="right">
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -1444,6 +1680,7 @@ import {
|
|||||||
prescriptionLists,
|
prescriptionLists,
|
||||||
prescriptionAdd,
|
prescriptionAdd,
|
||||||
prescriptionEdit,
|
prescriptionEdit,
|
||||||
|
prescriptionPatchPatient,
|
||||||
prescriptionDelete,
|
prescriptionDelete,
|
||||||
prescriptionAudit,
|
prescriptionAudit,
|
||||||
prescriptionDetail,
|
prescriptionDetail,
|
||||||
@@ -1470,6 +1707,126 @@ import jsPDF from 'jspdf'
|
|||||||
|
|
||||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
|
type FormulaType = '主方' | '辅方'
|
||||||
|
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||||
|
|
||||||
|
type AuxUsageForm = {
|
||||||
|
dosage_amount?: number
|
||||||
|
dosage_bag_count: number
|
||||||
|
need_decoction: boolean
|
||||||
|
bags_per_dose: number
|
||||||
|
times_per_day: number
|
||||||
|
usage_days: number
|
||||||
|
/** 从处方库导入辅方时写入的模板名称,供药房联展示 */
|
||||||
|
prescription_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuxUsageForm(raw: unknown, prescriptionType: string): AuxUsageForm {
|
||||||
|
const base = defaultAuxUsage(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,
|
||||||
|
prescription_name: String(o.prescription_name ?? '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsageSegmentText(
|
||||||
|
usage: {
|
||||||
|
prescription_type?: string
|
||||||
|
dosage_amount?: number | null
|
||||||
|
dosage_unit?: string
|
||||||
|
dosage_bag_count?: number
|
||||||
|
times_per_day?: number
|
||||||
|
usage_way?: string
|
||||||
|
usage_time?: string
|
||||||
|
},
|
||||||
|
fallbackWay?: string,
|
||||||
|
fallbackTime?: string
|
||||||
|
): string {
|
||||||
|
const pt = usage.prescription_type || '浓缩水丸'
|
||||||
|
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
|
||||||
|
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
|
||||||
|
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
|
||||||
|
const usageWay = usage.usage_way || fallbackWay || '温水送服'
|
||||||
|
const usageTime = usage.usage_time || fallbackTime || ''
|
||||||
|
const seg: string[] = []
|
||||||
|
seg.push(`每天${times}次`)
|
||||||
|
if (pt === '浓缩水丸') {
|
||||||
|
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
|
||||||
|
seg.push(`一次${bags}袋`)
|
||||||
|
seg.push(`每袋${amount}${unit}`)
|
||||||
|
} else {
|
||||||
|
seg.push(`一次${amount}${unit}`)
|
||||||
|
}
|
||||||
|
seg.push(usageWay)
|
||||||
|
if (usageTime) seg.push(usageTime)
|
||||||
|
return seg.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFormulaType(v: unknown): FormulaType {
|
||||||
|
return v === '辅方' ? '辅方' : '主方'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHerbRow(raw: any): HerbRow {
|
||||||
|
const row: HerbRow = {
|
||||||
|
name: String(raw?.name ?? '').trim(),
|
||||||
|
dosage: Number(raw?.dosage) || 0,
|
||||||
|
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||||
|
}
|
||||||
|
if (raw?.locked === true || raw?.locked === 1) {
|
||||||
|
row.locked = true
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
|
||||||
|
if (!raw) return []
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((x: any) => normalizeHerbRow(x))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
/** 与 server/config/project.php prescription_audit_roles 保持一致 */
|
/** 与 server/config/project.php prescription_audit_roles 保持一致 */
|
||||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
|
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
|
||||||
|
|
||||||
@@ -1531,6 +1888,66 @@ const auditTargetId = ref(0)
|
|||||||
const auditRemark = ref('')
|
const auditRemark = ref('')
|
||||||
const auditLoading = ref(false)
|
const auditLoading = ref(false)
|
||||||
|
|
||||||
|
/** 修正处方笺显示用姓名 / 手机号 */
|
||||||
|
const patchPatientVisible = ref(false)
|
||||||
|
const patchPatientSubmitLoading = ref(false)
|
||||||
|
const patchPatientFormRef = ref<FormInstance>()
|
||||||
|
const patchPatientForm = reactive({
|
||||||
|
id: 0,
|
||||||
|
patient_name: '',
|
||||||
|
gender: 1 as 0 | 1,
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
const patchPatientRules: FormRules = {
|
||||||
|
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
|
||||||
|
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
|
||||||
|
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPatchPatientDialog(row: Record<string, unknown>) {
|
||||||
|
patchPatientForm.id = Number(row.id) || 0
|
||||||
|
patchPatientForm.patient_name = String(row.patient_name ?? '').trim()
|
||||||
|
const g = Number(row.gender)
|
||||||
|
patchPatientForm.gender = g === 0 || g === 1 ? (g as 0 | 1) : 1
|
||||||
|
patchPatientForm.phone = String(row.phone ?? '').trim()
|
||||||
|
patchPatientVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPatchPatientForm() {
|
||||||
|
patchPatientForm.id = 0
|
||||||
|
patchPatientForm.patient_name = ''
|
||||||
|
patchPatientForm.gender = 1
|
||||||
|
patchPatientForm.phone = ''
|
||||||
|
patchPatientFormRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPatchPatient() {
|
||||||
|
const form = patchPatientFormRef.value
|
||||||
|
if (!form) return
|
||||||
|
try {
|
||||||
|
await form.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patchPatientSubmitLoading.value = true
|
||||||
|
try {
|
||||||
|
await prescriptionPatchPatient({
|
||||||
|
id: patchPatientForm.id,
|
||||||
|
patient_name: patchPatientForm.patient_name.trim(),
|
||||||
|
phone: patchPatientForm.phone.trim(),
|
||||||
|
gender: patchPatientForm.gender
|
||||||
|
})
|
||||||
|
feedback.msgSuccess('已保存')
|
||||||
|
patchPatientVisible.value = false
|
||||||
|
getLists()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e && typeof e === 'object' && 'msg' in e ? String((e as { msg?: string }).msg) : ''
|
||||||
|
feedback.msgError(msg || '保存失败')
|
||||||
|
} finally {
|
||||||
|
patchPatientSubmitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 从消费者处方创建订单 */
|
/** 从消费者处方创建订单 */
|
||||||
const createOrderVisible = ref(false)
|
const createOrderVisible = ref(false)
|
||||||
/** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */
|
/** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */
|
||||||
@@ -1811,7 +2228,13 @@ const createOrderHerbSummary = computed(() => {
|
|||||||
const row = createOrderPrescription.value
|
const row = createOrderPrescription.value
|
||||||
const herbs = normalizeSlipHerbs(row?.herbs)
|
const herbs = normalizeSlipHerbs(row?.herbs)
|
||||||
if (!herbs.length) return '暂无药材明细'
|
if (!herbs.length) return '暂无药材明细'
|
||||||
const s = herbs.map((h) => `${h.name} ${h.dosage}g`).join('、')
|
const main = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
|
||||||
|
const aux = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||||
|
const fmt = (list: HerbRow[]) => list.map((h) => `${h.name} ${h.dosage}g`).join('、')
|
||||||
|
const parts: string[] = []
|
||||||
|
if (main.length) parts.push(`主方:${fmt(main)}`)
|
||||||
|
if (aux.length) parts.push(`辅方:${fmt(aux)}`)
|
||||||
|
const s = parts.join(';')
|
||||||
return s.length > 120 ? `${s.slice(0, 120)}…` : s
|
return s.length > 120 ? `${s.slice(0, 120)}…` : s
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2051,9 +2474,33 @@ const editReviveHint = computed(() => {
|
|||||||
|
|
||||||
const slipHerbsList = computed(() => {
|
const slipHerbsList = computed(() => {
|
||||||
const h = slipView.value?.herbs
|
const h = slipView.value?.herbs
|
||||||
return Array.isArray(h) ? h : []
|
return Array.isArray(h) ? (h as HerbRow[]) : []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const slipMainHerbs = computed(() =>
|
||||||
|
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
|
||||||
|
)
|
||||||
|
const slipAuxHerbs = computed(() =>
|
||||||
|
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||||
|
)
|
||||||
|
|
||||||
|
const mainHerbRows = computed(() =>
|
||||||
|
editForm.herbs
|
||||||
|
.map((herb, index) => ({ herb, index }))
|
||||||
|
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '主方')
|
||||||
|
)
|
||||||
|
const auxHerbRows = computed(() =>
|
||||||
|
editForm.herbs
|
||||||
|
.map((herb, index) => ({ herb, index }))
|
||||||
|
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整张处方是否被锁定:只要任一药材来自「禁用修改」处方库模板即锁定。
|
||||||
|
* 锁定后不可增删改任意药材,只能再次「从处方库导入」覆盖。
|
||||||
|
*/
|
||||||
|
const herbsLocked = computed(() => editForm.herbs.some((h) => (h as HerbRow).locked === true))
|
||||||
|
|
||||||
const slipDietaryText = computed(() => {
|
const slipDietaryText = computed(() => {
|
||||||
const d = slipView.value?.dietary_taboo
|
const d = slipView.value?.dietary_taboo
|
||||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||||
@@ -2139,23 +2586,26 @@ const rxUsageText = computed(() => {
|
|||||||
const v = slipView.value as any
|
const v = slipView.value as any
|
||||||
if (!v) return '—'
|
if (!v) return '—'
|
||||||
if (v.usage_text) return v.usage_text
|
if (v.usage_text) return v.usage_text
|
||||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
return buildUsageSegmentText(v)
|
||||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
})
|
||||||
const unit = v.dosage_unit || 'g'
|
|
||||||
const usageWay = v.usage_way || '温水送服'
|
const rxAuxUsageText = computed(() => {
|
||||||
const usageTime = v.usage_time || ''
|
const v = slipView.value as any
|
||||||
const seg: string[] = []
|
if (!v || !slipAuxHerbs.value.length) return ''
|
||||||
seg.push(`每天${times}次`)
|
const aux = normalizeAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
|
||||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
return buildUsageSegmentText(
|
||||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
{
|
||||||
seg.push(`一次${bags}袋`)
|
prescription_type: v.prescription_type,
|
||||||
seg.push(`每袋${amount}${unit}`)
|
dosage_amount: aux.dosage_amount,
|
||||||
} else {
|
dosage_unit: v.dosage_unit,
|
||||||
seg.push(`一次${amount}${unit}`)
|
dosage_bag_count: aux.dosage_bag_count,
|
||||||
}
|
times_per_day: aux.times_per_day,
|
||||||
seg.push(usageWay)
|
usage_way: v.usage_way,
|
||||||
if (usageTime) seg.push(usageTime)
|
usage_time: v.usage_time
|
||||||
return seg.join(', ')
|
},
|
||||||
|
v.usage_way,
|
||||||
|
v.usage_time
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const rxAdviceText = computed(() => {
|
const rxAdviceText = computed(() => {
|
||||||
@@ -2350,7 +2800,7 @@ const editForm = reactive({
|
|||||||
pulse: '',
|
pulse: '',
|
||||||
pulse_condition: '',
|
pulse_condition: '',
|
||||||
clinical_diagnosis: '',
|
clinical_diagnosis: '',
|
||||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
herbs: [] as HerbRow[],
|
||||||
dose_count: 7,
|
dose_count: 7,
|
||||||
dose_unit: '剂',
|
dose_unit: '剂',
|
||||||
usage_days: 7,
|
usage_days: 7,
|
||||||
@@ -2376,7 +2826,8 @@ const editForm = reactive({
|
|||||||
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
|
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
|
||||||
business_prescription_audit_rejected: 0,
|
business_prescription_audit_rejected: 0,
|
||||||
business_prescription_audit_remark: '',
|
business_prescription_audit_remark: '',
|
||||||
times_per_day: 2
|
times_per_day: 2,
|
||||||
|
aux_usage: defaultAuxUsage('浓缩水丸')
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
|
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
|
||||||
@@ -2467,6 +2918,7 @@ watch(() => editForm.prescription_type, (newType) => {
|
|||||||
editForm.need_decoction = false
|
editForm.need_decoction = false
|
||||||
editForm.bags_per_dose = 1
|
editForm.bags_per_dose = 1
|
||||||
}
|
}
|
||||||
|
Object.assign(editForm.aux_usage, defaultAuxUsage(newType))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
@@ -2695,15 +3147,15 @@ function slipAgeText(age: unknown) {
|
|||||||
return `${age}岁`
|
return `${age}岁`
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
function herbValidationLabel(globalIndex: number): string {
|
||||||
if (!raw) return []
|
const herb = editForm.herbs[globalIndex]
|
||||||
if (Array.isArray(raw)) {
|
if (!herb) return `第${globalIndex + 1}味`
|
||||||
return raw.map((x: any) => ({
|
const ft = normalizeFormulaType(herb.formula_type)
|
||||||
name: String(x?.name ?? '').trim(),
|
let n = 0
|
||||||
dosage: Number(x?.dosage) || 0
|
for (let i = 0; i <= globalIndex; i++) {
|
||||||
}))
|
if (normalizeFormulaType(editForm.herbs[i]?.formula_type) === ft) n++
|
||||||
}
|
}
|
||||||
return []
|
return `${ft}第${n}味`
|
||||||
}
|
}
|
||||||
|
|
||||||
function listRxOrderWarnings(row: any): string[] {
|
function listRxOrderWarnings(row: any): string[] {
|
||||||
@@ -2926,6 +3378,8 @@ const showLibraryDialog = ref(false)
|
|||||||
const libraryLoading = ref(false)
|
const libraryLoading = ref(false)
|
||||||
const libraryList = ref<any[]>([])
|
const libraryList = ref<any[]>([])
|
||||||
const librarySearchName = ref('')
|
const librarySearchName = ref('')
|
||||||
|
const librarySearchFormulaType = ref('')
|
||||||
|
const libraryImportMode = ref<'replace' | 'append'>('replace')
|
||||||
const libraryPage = ref(1)
|
const libraryPage = ref(1)
|
||||||
const libraryPageSize = ref(15)
|
const libraryPageSize = ref(15)
|
||||||
const libraryTotal = ref(0)
|
const libraryTotal = ref(0)
|
||||||
@@ -2970,8 +3424,8 @@ const loadLibraryList = async () => {
|
|||||||
page_no: libraryPage.value,
|
page_no: libraryPage.value,
|
||||||
page_size: libraryPageSize.value,
|
page_size: libraryPageSize.value,
|
||||||
prescription_name: librarySearchName.value,
|
prescription_name: librarySearchName.value,
|
||||||
is_public: '',
|
formula_type: librarySearchFormulaType.value,
|
||||||
creator_id: doctorId
|
prescribing_creator_id: doctorId
|
||||||
})
|
})
|
||||||
libraryList.value = res?.lists || []
|
libraryList.value = res?.lists || []
|
||||||
libraryTotal.value = res?.count || 0
|
libraryTotal.value = res?.count || 0
|
||||||
@@ -3006,18 +3460,45 @@ const handleImportLibrary = (row: any) => {
|
|||||||
feedback.msgWarning('该处方没有药材信息')
|
feedback.msgWarning('该处方没有药材信息')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const imported = JSON.parse(JSON.stringify(row.herbs)) as Array<{ name: string; dosage: number }>
|
const formulaType = normalizeFormulaType(row.formula_type)
|
||||||
editForm.herbs = imported
|
// 「禁用修改」模板:导入药材打上锁定标记 → 整张处方锁定(不可增删改,只能再次导入覆盖)
|
||||||
|
const isLockedTemplate = Number(row?.disable_edit) === 1
|
||||||
|
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
|
||||||
|
const herb = normalizeHerbRow(h)
|
||||||
|
herb.formula_type = formulaType
|
||||||
|
if (isLockedTemplate) {
|
||||||
|
herb.locked = true
|
||||||
|
} else {
|
||||||
|
delete herb.locked
|
||||||
|
}
|
||||||
|
return herb
|
||||||
|
})
|
||||||
|
if (libraryImportMode.value === 'replace') {
|
||||||
|
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||||
|
editForm.herbs = [...kept, ...imported]
|
||||||
|
} else {
|
||||||
|
editForm.herbs.push(...imported)
|
||||||
|
}
|
||||||
|
if (formulaType === '辅方') {
|
||||||
|
editForm.aux_usage.prescription_name = String(row.prescription_name ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : ''
|
||||||
if (findDuplicateHerbNamesLocal().length) {
|
if (findDuplicateHerbNamesLocal().length) {
|
||||||
feedback.msgWarning('导入的处方中存在重复药名,请合并剂量或删除多余行')
|
feedback.msgWarning(
|
||||||
|
`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材。存在重复药名,请合并剂量或删除多余行`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
feedback.msgSuccess(`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材`)
|
||||||
}
|
}
|
||||||
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${imported.length}味药材`)
|
|
||||||
showLibraryDialog.value = false
|
showLibraryDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(showLibraryDialog, (open) => {
|
watch(showLibraryDialog, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
librarySearchName.value = ''
|
librarySearchName.value = ''
|
||||||
|
librarySearchFormulaType.value = ''
|
||||||
|
libraryImportMode.value = 'replace'
|
||||||
libraryPage.value = 1
|
libraryPage.value = 1
|
||||||
loadLibraryList()
|
loadLibraryList()
|
||||||
}
|
}
|
||||||
@@ -3149,7 +3630,7 @@ async function handlePasteRecipeImport() {
|
|||||||
}
|
}
|
||||||
pasteRecipeImportLoading.value = true
|
pasteRecipeImportLoading.value = true
|
||||||
try {
|
try {
|
||||||
const resolved: Array<{ name: string; dosage: number }> = []
|
const resolved: HerbRow[] = []
|
||||||
const skippedNames: string[] = []
|
const skippedNames: string[] = []
|
||||||
for (const row of parsed) {
|
for (const row of parsed) {
|
||||||
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
||||||
@@ -3157,7 +3638,7 @@ async function handlePasteRecipeImport() {
|
|||||||
skippedNames.push(row.name.trim())
|
skippedNames.push(row.name.trim())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
resolved.push({ name, dosage: row.dosage })
|
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||||
}
|
}
|
||||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||||
if (resolved.length === 0) {
|
if (resolved.length === 0) {
|
||||||
@@ -3169,7 +3650,8 @@ async function handlePasteRecipeImport() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pasteRecipeImportMode.value === 'replace') {
|
if (pasteRecipeImportMode.value === 'replace') {
|
||||||
editForm.herbs = resolved
|
const auxKept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||||
|
editForm.herbs = [...auxKept, ...resolved]
|
||||||
} else {
|
} else {
|
||||||
editForm.herbs.push(...resolved)
|
editForm.herbs.push(...resolved)
|
||||||
}
|
}
|
||||||
@@ -3194,16 +3676,25 @@ async function handlePasteRecipeImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加药材
|
// 添加药材(主方 / 辅方)
|
||||||
const addHerb = () => {
|
const addHerb = (formulaType: FormulaType = '主方') => {
|
||||||
|
if (herbsLocked.value) {
|
||||||
|
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
|
||||||
|
return
|
||||||
|
}
|
||||||
editForm.herbs.push({
|
editForm.herbs.push({
|
||||||
name: '',
|
name: '',
|
||||||
dosage: 0
|
dosage: 0,
|
||||||
|
formula_type: formulaType
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除药材
|
// 删除药材
|
||||||
const removeHerb = (index: number) => {
|
const removeHerb = (index: number) => {
|
||||||
|
if (herbsLocked.value) {
|
||||||
|
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
editForm.herbs.splice(index, 1)
|
editForm.herbs.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3248,6 +3739,8 @@ const resetForm = () => {
|
|||||||
editForm.is_system_auto = 0
|
editForm.is_system_auto = 0
|
||||||
editForm.business_prescription_audit_rejected = 0
|
editForm.business_prescription_audit_rejected = 0
|
||||||
editForm.business_prescription_audit_remark = ''
|
editForm.business_prescription_audit_remark = ''
|
||||||
|
editForm.times_per_day = 2
|
||||||
|
Object.assign(editForm.aux_usage, defaultAuxUsage('浓缩水丸'))
|
||||||
clearRxLinkedOrderHint()
|
clearRxLinkedOrderHint()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3346,7 +3839,7 @@ const handleEdit = async (row: any) => {
|
|||||||
editForm.pulse = src.pulse || ''
|
editForm.pulse = src.pulse || ''
|
||||||
editForm.pulse_condition = src.pulse_condition || ''
|
editForm.pulse_condition = src.pulse_condition || ''
|
||||||
editForm.clinical_diagnosis = src.clinical_diagnosis || ''
|
editForm.clinical_diagnosis = src.clinical_diagnosis || ''
|
||||||
editForm.herbs = src.herbs ? JSON.parse(JSON.stringify(src.herbs)) : []
|
editForm.herbs = src.herbs ? (src.herbs as any[]).map((h) => normalizeHerbRow(h)) : []
|
||||||
editForm.dose_count = src.dose_count ?? 7
|
editForm.dose_count = src.dose_count ?? 7
|
||||||
editForm.dose_unit = src.dose_unit || '剂'
|
editForm.dose_unit = src.dose_unit || '剂'
|
||||||
editForm.usage_days = src.usage_days ?? 7
|
editForm.usage_days = src.usage_days ?? 7
|
||||||
@@ -3371,6 +3864,10 @@ const handleEdit = async (row: any) => {
|
|||||||
editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0
|
editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0
|
||||||
editForm.business_prescription_audit_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0
|
editForm.business_prescription_audit_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0
|
||||||
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
|
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
|
||||||
|
Object.assign(
|
||||||
|
editForm.aux_usage,
|
||||||
|
normalizeAuxUsageForm(src.aux_usage, editForm.prescription_type)
|
||||||
|
)
|
||||||
|
|
||||||
// 数据加载完成,重新启用watch
|
// 数据加载完成,重新启用watch
|
||||||
// 使用 setTimeout 确保所有数据都已经渲染完成
|
// 使用 setTimeout 确保所有数据都已经渲染完成
|
||||||
@@ -3405,12 +3902,13 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
for (let i = 0; i < editForm.herbs.length; i++) {
|
for (let i = 0; i < editForm.herbs.length; i++) {
|
||||||
const herb = editForm.herbs[i]
|
const herb = editForm.herbs[i]
|
||||||
|
const label = herbValidationLabel(i)
|
||||||
if (!herb.name || !herb.name.trim()) {
|
if (!herb.name || !herb.name.trim()) {
|
||||||
feedback.msgError(`第${i + 1}味药材名称不能为空`)
|
feedback.msgError(`${label}药材名称不能为空`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!herb.dosage || herb.dosage <= 0) {
|
if (!herb.dosage || herb.dosage <= 0) {
|
||||||
feedback.msgError(`第${i + 1}味药材剂量必须大于0`)
|
feedback.msgError(`${label}药材剂量必须大于0`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3834,6 +4332,38 @@ onMounted(async () => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rx-herb-section-label {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409eff;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rx-herb-section-label--aux {
|
||||||
|
color: #e6a23c;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.herb-formula-block__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-formula-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-formula-title--aux {
|
||||||
|
color: #e6a23c;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.rx-herb-cell {
|
.rx-herb-cell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 64px;
|
grid-template-columns: 1fr 64px;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -405,6 +405,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-table v-loading="linesLoading" :data="linesRows" border stripe size="small" max-height="440">
|
<el-table v-loading="linesLoading" :data="linesRows" border stripe size="small" max-height="440">
|
||||||
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
|
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
|
||||||
|
<el-table-column label="患者" min-width="112" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cs-patient-cell">
|
||||||
|
<span class="cs-patient-cell__name">{{ row.patient_name || '—' }}</span>
|
||||||
|
<span v-if="row.patient_phone || row.patient_gender_text" class="cs-patient-cell__meta cs-muted">
|
||||||
|
<template v-if="row.patient_gender_text">{{ row.patient_gender_text }}</template>
|
||||||
|
<template v-if="row.patient_gender_text && row.patient_phone"> · </template>
|
||||||
|
<template v-if="row.patient_phone">{{ row.patient_phone }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
|
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
|
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -492,6 +504,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-table v-loading="leafOrdersLoading" :data="leafOrdersRows" border stripe size="small" max-height="440">
|
<el-table v-loading="leafOrdersLoading" :data="leafOrdersRows" border stripe size="small" max-height="440">
|
||||||
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
|
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
|
||||||
|
<el-table-column label="患者" min-width="112" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cs-patient-cell">
|
||||||
|
<span class="cs-patient-cell__name">{{ row.patient_name || '—' }}</span>
|
||||||
|
<span v-if="row.patient_phone || row.patient_gender_text" class="cs-patient-cell__meta cs-muted">
|
||||||
|
<template v-if="row.patient_gender_text">{{ row.patient_gender_text }}</template>
|
||||||
|
<template v-if="row.patient_gender_text && row.patient_phone"> · </template>
|
||||||
|
<template v-if="row.patient_phone">{{ row.patient_phone }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
|
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
|
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -948,10 +972,8 @@ async function loadLeafOrders(page: number) {
|
|||||||
page_size: leafOrdersPageSize.value,
|
page_size: leafOrdersPageSize.value,
|
||||||
}
|
}
|
||||||
if (d.mode === 'dept') {
|
if (d.mode === 'dept') {
|
||||||
p.dept_ids = String(d.dept_id)
|
Object.assign(p, buildQueryParams())
|
||||||
if (selectedChannel.value) {
|
p.table_row_dept_id = d.dept_id
|
||||||
p.channel_code = selectedChannel.value
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Object.assign(p, buildQueryParams())
|
Object.assign(p, buildQueryParams())
|
||||||
p.appt_channel_value = d.appt_channel_value
|
p.appt_channel_value = d.appt_channel_value
|
||||||
@@ -1981,6 +2003,21 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.cs-muted { color: var(--cs-muted); }
|
.cs-muted { color: var(--cs-muted); }
|
||||||
|
|
||||||
|
.cs-patient-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-patient-cell__name {
|
||||||
|
color: var(--cs-ink, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-patient-cell__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ Element Plus overrides (scoped via deep) ============ */
|
/* ============ Element Plus overrides (scoped via deep) ============ */
|
||||||
.cs-page {
|
.cs-page {
|
||||||
:deep(.el-input__wrapper),
|
:deep(.el-input__wrapper),
|
||||||
|
|||||||
+306
-49
@@ -127,10 +127,10 @@
|
|||||||
<span class="yeji-table-tabs-head__title">表格统计</span>
|
<span class="yeji-table-tabs-head__title">表格统计</span>
|
||||||
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览;「部门 / 排名」列在滚动时保持固定。</span>
|
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览;「部门 / 排名」列在滚动时保持固定。</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tabs v-model="yejiDisplayTab" class="yeji-view-tabs">
|
<el-tabs v-if="hasAnyYejiTableTab" v-model="yejiDisplayTab" class="yeji-view-tabs">
|
||||||
<el-tab-pane label="医生统计" name="doctor" lazy>
|
<el-tab-pane v-if="canViewDoctorTab" label="医生统计" name="doctor" lazy>
|
||||||
<el-card
|
<el-card
|
||||||
v-if="canViewDoctorDailyStats"
|
v-if="canViewDoctorTab"
|
||||||
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
|
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
|
||||||
shadow="never"
|
shadow="never"
|
||||||
>
|
>
|
||||||
@@ -181,15 +181,50 @@
|
|||||||
<template #default="{ row }">¥ {{ formatDoctorDailyMoney(row.deal_amount) }}</template>
|
<template #default="{ row }">¥ {{ formatDoctorDailyMoney(row.deal_amount) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="deal_order_count" label="接诊诊单" min-width="88" align="right" />
|
<el-table-column prop="deal_order_count" label="接诊诊单" min-width="88" align="right" />
|
||||||
|
<el-table-column
|
||||||
|
prop="appointment_total"
|
||||||
|
label="总挂号"
|
||||||
|
min-width="88"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>总挂号</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="区间内该医生 doctor_appointment 全部状态合计(已预约/已完成/已过号/已取消);选部门时仅计经手医助归属在所选部门子树的挂号(与业绩看板「预约诊单」医助口径一致)"
|
||||||
|
>
|
||||||
|
<el-icon class="doctor-daily-th__hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="appointment_completed" label="挂号完成" min-width="88" align="right" />
|
<el-table-column prop="appointment_completed" label="挂号完成" min-width="88" align="right" />
|
||||||
<el-table-column prop="appointment_missed" label="过号" min-width="72" align="right" />
|
<el-table-column prop="appointment_missed" label="过号" min-width="72" align="right" />
|
||||||
<el-table-column prop="appointment_cancelled" label="取消" min-width="72" align="right" />
|
<el-table-column prop="appointment_cancelled" label="取消" min-width="72" align="right" />
|
||||||
|
<el-table-column
|
||||||
|
prop="appointment_conversion_rate"
|
||||||
|
label="挂号率"
|
||||||
|
min-width="92"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>挂号率</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="挂号率 = 成交单数 / 总挂号数 × 100;总挂号为 0 时显示「—」"
|
||||||
|
>
|
||||||
|
<el-icon class="doctor-daily-th__hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">{{ formatDoctorDailyRate(row.appointment_conversion_rate) }}</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-empty v-else description="当前账号无医生统计权限" />
|
<el-empty v-else description="当前账号无医生统计权限" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="医助排行榜" name="leaderboard" lazy>
|
<el-tab-pane v-if="canViewLeaderboardTab" label="医助排行榜" name="leaderboard" lazy>
|
||||||
<div
|
<div
|
||||||
v-if="leaderboardBlock || leaderboardsLoading"
|
v-if="leaderboardBlock || leaderboardsLoading"
|
||||||
class="leaderboards-wrap"
|
class="leaderboards-wrap"
|
||||||
@@ -316,7 +351,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
<el-tab-pane v-if="canViewZyytTab" label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
||||||
<!-- ── 业绩表(4 张/N 张);目标看板见「目标看板(卡片)」Tab ── -->
|
<!-- ── 业绩表(4 张/N 张);目标看板见「目标看板(卡片)」Tab ── -->
|
||||||
<div v-loading="loading" class="tables-wrap">
|
<div v-loading="loading" class="tables-wrap">
|
||||||
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
||||||
@@ -355,7 +390,7 @@
|
|||||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||||
<b>tcm_diagnosis_assign_log</b> 行数(成功指派:<b>to_assistant_id > 0</b>),**仅按 <b>related_po_create_time</b>** 落在所选区间内(与日志表快照一致;诊单手动指派已写入该字段)。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
<b>tcm_diagnosis_assign_log</b> 中成功指派(<b>to_assistant_id > 0</b>,<b>剔除勾选「继承」的指派</b>)按<b>指派操作时间</b>落在所选区间内,「医助 × 诊单」去重、剔除已删诊单,与「复诊接诊率」页面同口径。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||||||
选定渠道时收窄与「{{ tb.channel_name }}」业绩同口径(标签诊单或 channels EXISTS)。<br />
|
选定渠道时收窄与「{{ tb.channel_name }}」业绩同口径(标签诊单或 channels EXISTS)。<br />
|
||||||
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>。
|
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>。
|
||||||
</template>
|
</template>
|
||||||
@@ -445,12 +480,8 @@
|
|||||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||||
<<<<<<< HEAD
|
|
||||||
<b>业务订单条数</b>(计业绩):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ∉ {4,9,10}</b>(<b>NULL 计入</b>);按<b>订单创建人</b>的人事部门落在该部门子树即计入(表格多行命中时取最深的展示部门)。与<b>合计业绩 / 医助排行榜接诊诊单</b>同口径。选定渠道时本列仍为全量。
|
|
||||||
=======
|
|
||||||
<b>业务订单条数</b>(计业绩):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ∉ {4,9,10}</b>;与列表筛选
|
<b>业务订单条数</b>(计业绩):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ∉ {4,9,10}</b>;与列表筛选
|
||||||
<b>assistant_dept_id</b> 时一致——<b>创建人</b>人事部门优先,无创建人则诊单 <b>医助</b>;落在该部门子树即计入(表格多行命中时取最深的展示部门)。与侧栏勾选「与表格业绩对齐」时的集合可能略有差异。选定渠道时本列仍为全量。
|
<b>assistant_dept_id</b> 时一致——<b>创建人</b>人事部门优先,无创建人则诊单 <b>医助</b>;落在该部门子树即计入(表格多行命中时取最深的展示部门)。与侧栏勾选「与表格业绩对齐」时的集合可能略有差异。选定渠道时本列仍为全量。
|
||||||
>>>>>>> master
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||||||
@@ -636,7 +667,7 @@
|
|||||||
|
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane label="目标看板(卡片)" name="zyyt_matrix" lazy>
|
<el-tab-pane v-if="canViewTargetMatrixTab" label="目标看板(卡片)" name="zyyt_matrix" lazy>
|
||||||
<div v-loading="targetProgressLoading" class="tp-matrix-wrap">
|
<div v-loading="targetProgressLoading" class="tp-matrix-wrap">
|
||||||
<template v-if="targetProgressCard">
|
<template v-if="targetProgressCard">
|
||||||
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
|
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
|
||||||
@@ -702,18 +733,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
<el-empty v-else description="当前账号无表格统计 Tab 权限" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="yeji-panel yeji-charts-only-card" shadow="never">
|
<el-card class="yeji-panel yeji-charts-only-card" shadow="never">
|
||||||
<div class="yeji-charts-only-head">统计图</div>
|
<div class="yeji-charts-only-head">统计图</div>
|
||||||
<div
|
<div
|
||||||
v-loading="loading || (canViewDoctorDailyStats && doctorDailyLoading) || leaderboardsLoading"
|
v-loading="loading || (canViewDoctorTab && doctorDailyLoading) || (canViewLeaderboardTab && leaderboardsLoading)"
|
||||||
class="yeji-charts-stack"
|
class="yeji-charts-stack"
|
||||||
>
|
>
|
||||||
<template v-if="chartsHasAnyData">
|
<template v-if="chartsHasAnyData">
|
||||||
<section
|
<section
|
||||||
v-if="
|
v-if="
|
||||||
canViewDoctorDailyStats &&
|
canViewDoctorTab &&
|
||||||
(doctorDealBarHasData ||
|
(doctorDealBarHasData ||
|
||||||
doctorRxStackHasData ||
|
doctorRxStackHasData ||
|
||||||
doctorAppointmentPieHasData ||
|
doctorAppointmentPieHasData ||
|
||||||
@@ -855,7 +887,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="leaderboardBlock">
|
<template v-if="canViewLeaderboardTab && leaderboardBlock">
|
||||||
<section
|
<section
|
||||||
v-for="lb in leaderboardChartBlocks"
|
v-for="lb in leaderboardChartBlocks"
|
||||||
:key="'lb-chart-' + lb.dept_id"
|
:key="'lb-chart-' + lb.dept_id"
|
||||||
@@ -898,8 +930,8 @@
|
|||||||
<el-empty
|
<el-empty
|
||||||
v-else-if="
|
v-else-if="
|
||||||
!loading &&
|
!loading &&
|
||||||
(!canViewDoctorDailyStats || !doctorDailyLoading) &&
|
(!canViewDoctorTab || !doctorDailyLoading) &&
|
||||||
!leaderboardsLoading
|
(!canViewLeaderboardTab || !leaderboardsLoading)
|
||||||
"
|
"
|
||||||
class="yeji-charts-empty"
|
class="yeji-charts-empty"
|
||||||
description="当前筛选下暂无图表数据,请调整条件后查询"
|
description="当前筛选下暂无图表数据,请调整条件后查询"
|
||||||
@@ -1159,9 +1191,19 @@
|
|||||||
class="yeji-leadlines-dialog"
|
class="yeji-leadlines-dialog"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="yeji-unassigned-dialog__head">
|
<div class="yeji-unassigned-dialog__head yeji-leadlines-dialog__head">
|
||||||
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
|
<div class="yeji-leadlines-dialog__head-main">
|
||||||
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
|
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
|
||||||
|
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:loading="leadLinesExporting"
|
||||||
|
:disabled="leadLinesLoading || leadLinesExporting || leadLinesCount <= 0"
|
||||||
|
@click="exportLeadLines"
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
|
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
|
||||||
@@ -1318,7 +1360,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
|
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -1337,7 +1379,14 @@ import {
|
|||||||
} from '@/api/stats'
|
} from '@/api/stats'
|
||||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||||
import { prescriptionOrderLists } from '@/api/tcm'
|
import { prescriptionOrderLists } from '@/api/tcm'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
|
type YejiDisplayTab = 'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'
|
||||||
|
|
||||||
|
/** 任一权限命中即可(兼容原有接口按钮权限 + 新增 Tab 独立权限) */
|
||||||
|
function hasAnyPermission(perms: string[]): boolean {
|
||||||
|
return perms.some(p => hasPermission([p]))
|
||||||
|
}
|
||||||
|
|
||||||
interface DeptOption {
|
interface DeptOption {
|
||||||
id: number
|
id: number
|
||||||
@@ -1626,6 +1675,7 @@ const revisitBreakdownMeta = ref<{ start: string; end: string; revisitSlot: numb
|
|||||||
|
|
||||||
const leadLinesDialogVisible = ref(false)
|
const leadLinesDialogVisible = ref(false)
|
||||||
const leadLinesLoading = ref(false)
|
const leadLinesLoading = ref(false)
|
||||||
|
const leadLinesExporting = ref(false)
|
||||||
const leadLinesSubtitle = ref('')
|
const leadLinesSubtitle = ref('')
|
||||||
const leadLinesApiNote = ref('')
|
const leadLinesApiNote = ref('')
|
||||||
const leadLinesRows = ref<YejiLeadLineRow[]>([])
|
const leadLinesRows = ref<YejiLeadLineRow[]>([])
|
||||||
@@ -1664,14 +1714,60 @@ const appointmentLinesShowChannelColumn = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** 图表 / 数据表切换 */
|
/** 图表 / 数据表切换 */
|
||||||
const yejiDisplayTab = ref<'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'>('zyyt')
|
const yejiDisplayTab = ref<YejiDisplayTab>('zyyt')
|
||||||
|
|
||||||
/** 与菜单 stats.doctorDailyStats/overview 一致;无权限则不展示医生统计表/图、不请求接口 */
|
/** Tab 可见性:优先认 Tab 独立权限;未配置时兼容原有接口按钮权限,避免破坏已有角色 */
|
||||||
const userStore = useUserStore()
|
const YEJI_TAB_DOCTOR_PERM = 'stats.yejiStats/tabDoctor'
|
||||||
const DOCTOR_DAILY_STATS_PERM = 'stats.doctorDailyStats/overview'
|
const YEJI_TAB_LEADERBOARD_PERM = 'stats.yejiStats/tabLeaderboard'
|
||||||
const canViewDoctorDailyStats = computed(() => {
|
const YEJI_TAB_ZYYT_PERM = 'stats.yejiStats/tabZyyt'
|
||||||
const perms = userStore.perms || []
|
const YEJI_TAB_TARGET_MATRIX_PERM = 'stats.yejiStats/tabTargetMatrix'
|
||||||
return perms.some(p => p === '*' || p === DOCTOR_DAILY_STATS_PERM)
|
|
||||||
|
const canViewDoctorTab = computed(() =>
|
||||||
|
hasAnyPermission([YEJI_TAB_DOCTOR_PERM, 'stats.doctorDailyStats/overview'])
|
||||||
|
)
|
||||||
|
const canViewLeaderboardTab = computed(() =>
|
||||||
|
hasAnyPermission([YEJI_TAB_LEADERBOARD_PERM, 'stats.yejiStats/leaderboard'])
|
||||||
|
)
|
||||||
|
const canViewZyytTab = computed(() =>
|
||||||
|
hasAnyPermission([
|
||||||
|
YEJI_TAB_ZYYT_PERM,
|
||||||
|
'stats.yejiStats/multi',
|
||||||
|
'stats.yejiStats/overview',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
const canViewTargetMatrixTab = computed(() =>
|
||||||
|
hasAnyPermission([
|
||||||
|
YEJI_TAB_TARGET_MATRIX_PERM,
|
||||||
|
'stats.yejiStats/targetMatrix',
|
||||||
|
'stats.yejiStats/multi',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
const hasAnyYejiTableTab = computed(
|
||||||
|
() =>
|
||||||
|
canViewDoctorTab.value ||
|
||||||
|
canViewLeaderboardTab.value ||
|
||||||
|
canViewZyytTab.value ||
|
||||||
|
canViewTargetMatrixTab.value
|
||||||
|
)
|
||||||
|
|
||||||
|
function pickInitialYejiTab(): YejiDisplayTab {
|
||||||
|
if (canViewZyytTab.value) return 'zyyt'
|
||||||
|
if (canViewDoctorTab.value) return 'doctor'
|
||||||
|
if (canViewLeaderboardTab.value) return 'leaderboard'
|
||||||
|
if (canViewTargetMatrixTab.value) return 'zyyt_matrix'
|
||||||
|
return 'zyyt'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(yejiDisplayTab, tab => {
|
||||||
|
if (tab === 'doctor' && !canViewDoctorTab.value) {
|
||||||
|
yejiDisplayTab.value = pickInitialYejiTab()
|
||||||
|
} else if (tab === 'leaderboard' && !canViewLeaderboardTab.value) {
|
||||||
|
yejiDisplayTab.value = pickInitialYejiTab()
|
||||||
|
} else if (tab === 'zyyt' && !canViewZyytTab.value) {
|
||||||
|
yejiDisplayTab.value = pickInitialYejiTab()
|
||||||
|
} else if (tab === 'zyyt_matrix' && !canViewTargetMatrixTab.value) {
|
||||||
|
yejiDisplayTab.value = pickInitialYejiTab()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const leaderboardChartBlocks = computed(() =>
|
const leaderboardChartBlocks = computed(() =>
|
||||||
@@ -1715,7 +1811,7 @@ const doctorRxStackHasData = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const doctorChartsHasData = computed(() => {
|
const doctorChartsHasData = computed(() => {
|
||||||
if (!canViewDoctorDailyStats.value) {
|
if (!canViewDoctorTab.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -1731,12 +1827,14 @@ const chartsHasAnyData = computed(() => {
|
|||||||
if (doctorChartsHasData.value) {
|
if (doctorChartsHasData.value) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for (const tb of tables.value) {
|
if (canViewZyytTab.value) {
|
||||||
if (yejiDeptChartHasData(tb)) {
|
for (const tb of tables.value) {
|
||||||
return true
|
if (yejiDeptChartHasData(tb)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (leaderboardChartBlocks.value.length > 0) {
|
if (canViewLeaderboardTab.value && leaderboardChartBlocks.value.length > 0) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -2015,6 +2113,10 @@ function buildDoctorDailyRequestParams(): Record<string, any> {
|
|||||||
if (selectedChannel.value) {
|
if (selectedChannel.value) {
|
||||||
p.channel_code = selectedChannel.value
|
p.channel_code = selectedChannel.value
|
||||||
}
|
}
|
||||||
|
// 选中父级时由后端 resolvePrimaryDepts 自动展开所有子级,再用 admin_dept 收窄医生集合。
|
||||||
|
if (selectedDeptIds.value.length > 0) {
|
||||||
|
p.dept_ids = selectedDeptIds.value.join(',')
|
||||||
|
}
|
||||||
if (customRange.value?.[0] && customRange.value?.[1]) {
|
if (customRange.value?.[0] && customRange.value?.[1]) {
|
||||||
p.start_date = customRange.value[0]
|
p.start_date = customRange.value[0]
|
||||||
p.end_date = customRange.value[1]
|
p.end_date = customRange.value[1]
|
||||||
@@ -2033,6 +2135,15 @@ function formatDoctorDailyMoney(n: number | string | undefined) {
|
|||||||
return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 挂号率展示:null 或非有限数显示「—」;保留 2 位小数 + % 后缀 */
|
||||||
|
function formatDoctorDailyRate(v: number | string | null | undefined): string {
|
||||||
|
if (v === null || v === undefined || v === '') return '—'
|
||||||
|
const n = Number(v)
|
||||||
|
if (!Number.isFinite(n)) return '—'
|
||||||
|
|
||||||
|
return `${n.toFixed(2)}%`
|
||||||
|
}
|
||||||
|
|
||||||
function getDoctorDailySummaries(param: { columns: any[] }) {
|
function getDoctorDailySummaries(param: { columns: any[] }) {
|
||||||
const t = doctorDailyTotal.value
|
const t = doctorDailyTotal.value
|
||||||
const sums: string[] = []
|
const sums: string[] = []
|
||||||
@@ -2055,6 +2166,9 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
|
|||||||
case 'deal_order_count':
|
case 'deal_order_count':
|
||||||
sums[index] = String(t.deal_order_count ?? 0)
|
sums[index] = String(t.deal_order_count ?? 0)
|
||||||
break
|
break
|
||||||
|
case 'appointment_total':
|
||||||
|
sums[index] = String(t.appointment_total ?? 0)
|
||||||
|
break
|
||||||
case 'appointment_completed':
|
case 'appointment_completed':
|
||||||
sums[index] = String(t.appointment_completed ?? 0)
|
sums[index] = String(t.appointment_completed ?? 0)
|
||||||
break
|
break
|
||||||
@@ -2064,6 +2178,9 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
|
|||||||
case 'appointment_cancelled':
|
case 'appointment_cancelled':
|
||||||
sums[index] = String(t.appointment_cancelled ?? 0)
|
sums[index] = String(t.appointment_cancelled ?? 0)
|
||||||
break
|
break
|
||||||
|
case 'appointment_conversion_rate':
|
||||||
|
sums[index] = formatDoctorDailyRate(t.appointment_conversion_rate)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
sums[index] = ''
|
sums[index] = ''
|
||||||
}
|
}
|
||||||
@@ -2073,6 +2190,12 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDoctorDailyStats() {
|
async function loadDoctorDailyStats() {
|
||||||
|
if (!canViewDoctorTab.value) {
|
||||||
|
doctorDailyRows.value = []
|
||||||
|
doctorDailyTotal.value = {}
|
||||||
|
doctorDailyRange.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
doctorDailyLoading.value = true
|
doctorDailyLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
|
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
|
||||||
@@ -2211,6 +2334,10 @@ function daysInMonth(ym: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTargetProgress() {
|
async function loadTargetProgress() {
|
||||||
|
if (!canViewTargetMatrixTab.value) {
|
||||||
|
targetProgressCard.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
targetProgressLoading.value = true
|
targetProgressLoading.value = true
|
||||||
targetProgressCard.value = null
|
targetProgressCard.value = null
|
||||||
try {
|
try {
|
||||||
@@ -2458,6 +2585,10 @@ async function loadChannelOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLeaderboard(range: { start: string; end: string }) {
|
async function loadLeaderboard(range: { start: string; end: string }) {
|
||||||
|
if (!canViewLeaderboardTab.value) {
|
||||||
|
leaderboardBlock.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
leaderboardsLoading.value = true
|
leaderboardsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const p: Record<string, any> = {
|
const p: Record<string, any> = {
|
||||||
@@ -2491,6 +2622,18 @@ async function loadLeaderboard(range: { start: string; end: string }) {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
leaderboardBlock.value = null
|
leaderboardBlock.value = null
|
||||||
|
if (!canViewZyytTab.value) {
|
||||||
|
tables.value = []
|
||||||
|
loading.value = false
|
||||||
|
if (canViewDoctorTab.value) {
|
||||||
|
void loadDoctorDailyStats()
|
||||||
|
} else {
|
||||||
|
doctorDailyRows.value = []
|
||||||
|
doctorDailyTotal.value = {}
|
||||||
|
doctorDailyRange.value = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const baseParams: Record<string, any> = {}
|
const baseParams: Record<string, any> = {}
|
||||||
if (selectedDeptIds.value.length > 0) {
|
if (selectedDeptIds.value.length > 0) {
|
||||||
@@ -2542,7 +2685,7 @@ async function loadData() {
|
|||||||
ElMessage.error(any?.msg || any?.message || '加载失败')
|
ElMessage.error(any?.msg || any?.message || '加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (canViewDoctorDailyStats.value) {
|
if (canViewDoctorTab.value) {
|
||||||
void loadDoctorDailyStats()
|
void loadDoctorDailyStats()
|
||||||
} else {
|
} else {
|
||||||
doctorDailyRows.value = []
|
doctorDailyRows.value = []
|
||||||
@@ -2661,6 +2804,9 @@ function buildOrderDrawerListParams(): Record<string, any> | null {
|
|||||||
if (selectedDeptIds.value.length > 0) {
|
if (selectedDeptIds.value.length > 0) {
|
||||||
params.dept_ids = selectedDeptIds.value.join(',')
|
params.dept_ids = selectedDeptIds.value.join(',')
|
||||||
}
|
}
|
||||||
|
if (f.yejiTableRowDeptIds && f.yejiTableRowDeptIds.length > 0) {
|
||||||
|
params.yeji_table_row_dept_ids = f.yejiTableRowDeptIds.join(',')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
params.assistant_id = f.assistantId
|
params.assistant_id = f.assistantId
|
||||||
if (f.erCenterRevisitSlot !== undefined) {
|
if (f.erCenterRevisitSlot !== undefined) {
|
||||||
@@ -3047,28 +3193,79 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
|||||||
void openLeadLinesDialog(tb, row)
|
void openLeadLinesDialog(tb, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
|
||||||
|
{ key: 'event_time_text', label: '进线时间' },
|
||||||
|
{ key: 'reception_admin_name', label: '接待' },
|
||||||
|
{ key: 'external_contact_name', label: '客户' },
|
||||||
|
{ key: 'external_userid', label: '外部联系人ID' },
|
||||||
|
{ key: 'user_id', label: '企微成员ID' },
|
||||||
|
{ key: 'state', label: '渠道参数' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildLeadLinesRequestParams(
|
||||||
|
ctx: { tb: YejiTable; row: YejiRow },
|
||||||
|
page: number,
|
||||||
|
pageSize: number
|
||||||
|
): Record<string, string | number> {
|
||||||
|
const { tb, row } = ctx
|
||||||
|
const p: Record<string, string | number> = {
|
||||||
|
start_date: tb.start_date,
|
||||||
|
end_date: tb.end_date,
|
||||||
|
dept_id: row.dept_id,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
}
|
||||||
|
if (selectedDeptIds.value.length > 0) {
|
||||||
|
p.dept_ids = selectedDeptIds.value.join(',')
|
||||||
|
}
|
||||||
|
if (selectedChannel.value) {
|
||||||
|
p.channel_code = selectedChannel.value
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLeadLinesCsvCell(value: unknown): string {
|
||||||
|
const s = value == null ? '' : String(value)
|
||||||
|
if (/[",\n\r]/.test(s)) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLeadLinesCsv(rows: YejiLeadLineRow[]): string {
|
||||||
|
const header = LEAD_LINES_EXPORT_COLUMNS.map((c) => escapeLeadLinesCsvCell(c.label)).join(',')
|
||||||
|
const body = rows
|
||||||
|
.map((row) => LEAD_LINES_EXPORT_COLUMNS.map((c) => escapeLeadLinesCsvCell(row[c.key])).join(','))
|
||||||
|
.join('\n')
|
||||||
|
return `\uFEFF${header}\n${body}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadLeadLinesCsv(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = filename
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLeadLinesExportFilename(ctx: { tb: YejiTable; row: YejiRow }): string {
|
||||||
|
const { tb, row } = ctx
|
||||||
|
const safeDept = (row.dept_name || `dept${row.dept_id}`).replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
return `进线数据明细_${safeDept}_${tb.start_date}_${tb.end_date}.csv`
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchLeadLinesPage() {
|
async function fetchLeadLinesPage() {
|
||||||
const ctx = leadLinesContext.value
|
const ctx = leadLinesContext.value
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { tb, row } = ctx
|
|
||||||
leadLinesLoading.value = true
|
leadLinesLoading.value = true
|
||||||
try {
|
try {
|
||||||
const p: Record<string, string | number> = {
|
const res: any = await yejiStatsLeadLines(
|
||||||
start_date: tb.start_date,
|
buildLeadLinesRequestParams(ctx, leadLinesPage.value, leadLinesPageSize.value) as any
|
||||||
end_date: tb.end_date,
|
)
|
||||||
dept_id: row.dept_id,
|
|
||||||
page: leadLinesPage.value,
|
|
||||||
page_size: leadLinesPageSize.value,
|
|
||||||
}
|
|
||||||
if (selectedDeptIds.value.length > 0) {
|
|
||||||
p.dept_ids = selectedDeptIds.value.join(',')
|
|
||||||
}
|
|
||||||
if (selectedChannel.value) {
|
|
||||||
p.channel_code = selectedChannel.value
|
|
||||||
}
|
|
||||||
const res: any = await yejiStatsLeadLines(p as any)
|
|
||||||
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||||||
leadLinesCount.value = Number(res?.count ?? 0)
|
leadLinesCount.value = Number(res?.count ?? 0)
|
||||||
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||||||
@@ -3107,6 +3304,40 @@ function onLeadLinesPageSizeChange(size: number) {
|
|||||||
void fetchLeadLinesPage()
|
void fetchLeadLinesPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportLeadLines() {
|
||||||
|
const ctx = leadLinesContext.value
|
||||||
|
if (!ctx || leadLinesExporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
leadLinesExporting.value = true
|
||||||
|
try {
|
||||||
|
const pageSize = 100
|
||||||
|
const firstRes: any = await yejiStatsLeadLines(buildLeadLinesRequestParams(ctx, 1, pageSize) as any)
|
||||||
|
const total = Number(firstRes?.count ?? 0)
|
||||||
|
if (total <= 0) {
|
||||||
|
ElMessage.warning('暂无进线明细可导出')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const allRows: YejiLeadLineRow[] = Array.isArray(firstRes?.lists) ? [...firstRes.lists] : []
|
||||||
|
const totalPages = Math.ceil(total / pageSize)
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const res: any = await yejiStatsLeadLines(buildLeadLinesRequestParams(ctx, page, pageSize) as any)
|
||||||
|
const lists = Array.isArray(res?.lists) ? res.lists : []
|
||||||
|
allRows.push(...lists)
|
||||||
|
}
|
||||||
|
downloadLeadLinesCsv(buildLeadLinesExportFilename(ctx), buildLeadLinesCsv(allRows))
|
||||||
|
ElMessage.success(`已导出 ${allRows.length} 条进线明细`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (axios.isCancel(e)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const any = e as any
|
||||||
|
ElMessage.error(any?.msg || any?.message || '导出进线明细失败')
|
||||||
|
} finally {
|
||||||
|
leadLinesExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function appointmentLinesIndexMethod(index: number) {
|
function appointmentLinesIndexMethod(index: number) {
|
||||||
return (appointmentLinesPage.value - 1) * appointmentLinesPageSize.value + index + 1
|
return (appointmentLinesPage.value - 1) * appointmentLinesPageSize.value + index + 1
|
||||||
}
|
}
|
||||||
@@ -3561,6 +3792,7 @@ function formatLocalYmd(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
yejiDisplayTab.value = pickInitialYejiTab()
|
||||||
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
||||||
loadData()
|
loadData()
|
||||||
loadTargetProgress()
|
loadTargetProgress()
|
||||||
@@ -3922,6 +4154,19 @@ onMounted(async () => {
|
|||||||
background: var(--yj-accent-soft) !important;
|
background: var(--yj-accent-soft) !important;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doctor-daily-th__hint {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--yj-muted);
|
||||||
|
cursor: help;
|
||||||
|
vertical-align: -2px;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--yj-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-surface {
|
.filter-surface {
|
||||||
@@ -4832,6 +5077,18 @@ tbody tr.total-row:hover .yeji-lead-cell--link {
|
|||||||
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
|
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.yeji-leadlines-dialog__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yeji-leadlines-dialog__head-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.yeji-leadlines-dialog__pager {
|
.yeji-leadlines-dialog__pager {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -22,16 +22,25 @@
|
|||||||
<el-select
|
<el-select
|
||||||
v-model="formData.media_channel_code"
|
v-model="formData.media_channel_code"
|
||||||
placeholder="请选择自媒体渠道"
|
placeholder="请选择自媒体渠道"
|
||||||
class="w-full"
|
class="account-cost-channel-select w-full"
|
||||||
filterable
|
filterable
|
||||||
:disabled="mode === 'edit'"
|
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option-group
|
||||||
v-for="item in mediaChannelOptions"
|
v-for="g in mediaChannelGroups"
|
||||||
:key="item.code"
|
:key="g.group_name"
|
||||||
:label="item.name"
|
:label="g.group_name"
|
||||||
:value="item.code"
|
>
|
||||||
/>
|
<el-option
|
||||||
|
v-for="ch in g.channels"
|
||||||
|
:key="ch.channel_code"
|
||||||
|
:label="ch.channel_name"
|
||||||
|
:value="ch.channel_code"
|
||||||
|
>
|
||||||
|
<div class="channel-opt-row">
|
||||||
|
<span class="opt-name">{{ ch.channel_name }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="部门" prop="dept_id">
|
<el-form-item label="部门" prop="dept_id">
|
||||||
@@ -78,9 +87,9 @@ import type { FormInstance } from 'element-plus'
|
|||||||
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
|
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
|
||||||
import Popup from '@/components/popup/index.vue'
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
interface MediaChannelOption {
|
interface MediaChannelGroup {
|
||||||
code: string
|
group_name: string
|
||||||
name: string
|
channels: { channel_code: string; channel_name: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeptOption {
|
interface DeptOption {
|
||||||
@@ -90,7 +99,7 @@ interface DeptOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
mediaChannelOptions: MediaChannelOption[]
|
mediaChannelGroups: MediaChannelGroup[]
|
||||||
deptOptions: DeptOption[]
|
deptOptions: DeptOption[]
|
||||||
defaultMediaChannelCode: string
|
defaultMediaChannelCode: string
|
||||||
}>()
|
}>()
|
||||||
@@ -197,3 +206,21 @@ defineExpose({
|
|||||||
getDetail,
|
getDetail,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.account-cost-channel-select {
|
||||||
|
:deep(.channel-opt-row) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
:deep(.opt-name) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -14,27 +14,38 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||||
<el-form-item label="日期范围">
|
<list-time-filter
|
||||||
<daterange-picker
|
v-model:time-type="time_type"
|
||||||
v-model:startTime="queryParams.start_date"
|
v-model:date-range="dateRange"
|
||||||
v-model:endTime="queryParams.end_date"
|
@time-type-change="onTimeTypeChange"
|
||||||
/>
|
@custom-change="onCustomDateChange"
|
||||||
</el-form-item>
|
/>
|
||||||
<el-form-item label="自媒体渠道">
|
<el-form-item label="自媒体渠道">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="queryParams.media_channel_code"
|
v-model="queryParams.media_channel_code"
|
||||||
placeholder="筛选全部渠道"
|
placeholder="筛选全部渠道"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
class="w-[220px]"
|
class="account-cost-channel-select w-[220px]"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option label="全部" value="" />
|
||||||
v-for="item in mediaChannelOptions"
|
<el-option-group
|
||||||
:key="item.code"
|
v-for="g in mediaChannelGroups"
|
||||||
:label="item.name"
|
:key="g.group_name"
|
||||||
:value="item.code"
|
:label="g.group_name"
|
||||||
/>
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="ch in g.channels"
|
||||||
|
:key="ch.channel_code"
|
||||||
|
:label="ch.channel_name"
|
||||||
|
:value="ch.channel_code"
|
||||||
|
>
|
||||||
|
<div class="channel-opt-row">
|
||||||
|
<span class="opt-name">{{ ch.channel_name }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="部门">
|
<el-form-item label="部门">
|
||||||
@@ -56,11 +67,11 @@
|
|||||||
v-model="queryParams.remark"
|
v-model="queryParams.remark"
|
||||||
placeholder="备注 / 创建人 / 修改人"
|
placeholder="备注 / 创建人 / 修改人"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="resetPage"
|
@keyup.enter="handleQuery"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -78,7 +89,15 @@
|
|||||||
|
|
||||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||||
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||||
<el-table-column label="自媒体渠道" prop="media_channel_name" min-width="120" />
|
<el-table-column label="自媒体渠道" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="getChannelGroupLabel(row.media_channel_code)">
|
||||||
|
<el-tag size="small" class="mr-1">{{ getChannelGroupLabel(row.media_channel_code) }}</el-tag>
|
||||||
|
{{ row.media_channel_name }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ row.media_channel_name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="部门" prop="dept_name" min-width="140" />
|
<el-table-column label="部门" prop="dept_name" min-width="140" />
|
||||||
<el-table-column label="账户消耗" min-width="120">
|
<el-table-column label="账户消耗" min-width="120">
|
||||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||||
@@ -117,7 +136,7 @@
|
|||||||
<edit-popup
|
<edit-popup
|
||||||
v-if="showEdit"
|
v-if="showEdit"
|
||||||
ref="editRef"
|
ref="editRef"
|
||||||
:media-channel-options="mediaChannelOptions"
|
:media-channel-groups="mediaChannelGroups"
|
||||||
:dept-options="deptOptions"
|
:dept-options="deptOptions"
|
||||||
:default-media-channel-code="defaultMediaChannelCode"
|
:default-media-channel-code="defaultMediaChannelCode"
|
||||||
@success="getLists"
|
@success="getLists"
|
||||||
@@ -129,10 +148,17 @@
|
|||||||
<script lang="ts" setup name="accountCostList">
|
<script lang="ts" setup name="accountCostList">
|
||||||
import { accountCostDelete, accountCostLists } from '@/api/finance'
|
import { accountCostDelete, accountCostLists } from '@/api/finance'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
|
|
||||||
import EditPopup from './edit.vue'
|
import EditPopup from './edit.vue'
|
||||||
|
|
||||||
|
interface MediaChannelGroup {
|
||||||
|
group_name: string
|
||||||
|
channels: { channel_code: string; channel_name: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
interface MediaChannelOption {
|
interface MediaChannelOption {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
@@ -148,19 +174,41 @@ const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
|||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
|
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
media_channel_code: '',
|
media_channel_code: '',
|
||||||
dept_id: '',
|
dept_id: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { pager, getLists, resetPage } = usePaging({
|
const {
|
||||||
fetchFun: accountCostLists,
|
time_type,
|
||||||
|
dateRange,
|
||||||
|
resolve,
|
||||||
|
handleTimeTypeChange,
|
||||||
|
handleCustomDateChange,
|
||||||
|
resetTimeFilter,
|
||||||
|
} = useListTimeFilter('today')
|
||||||
|
|
||||||
|
const { pager, getLists } = usePaging({
|
||||||
|
fetchFun: async (params: Record<string, any>) => {
|
||||||
|
const [start_date, end_date] = resolve()
|
||||||
|
return accountCostLists({
|
||||||
|
...params,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
})
|
||||||
|
},
|
||||||
params: queryParams,
|
params: queryParams,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
|
const handleQuery = () => {
|
||||||
|
pager.page = 1
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
|
||||||
|
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
|
||||||
|
|
||||||
|
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
|
||||||
const options = pager.extend.media_channel_options
|
const options = pager.extend.media_channel_options
|
||||||
if (!Array.isArray(options)) return []
|
if (!Array.isArray(options)) return []
|
||||||
|
|
||||||
@@ -170,6 +218,26 @@ const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mediaChannelGroups = computed<MediaChannelGroup[]>(() => {
|
||||||
|
const groups = pager.extend.media_channel_groups
|
||||||
|
if (Array.isArray(groups) && groups.length > 0) {
|
||||||
|
return groups as MediaChannelGroup[]
|
||||||
|
}
|
||||||
|
const flat = mediaChannelOptionsFlat.value
|
||||||
|
if (!flat.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
group_name: '渠道',
|
||||||
|
channels: flat.map(item => ({
|
||||||
|
channel_code: item.code,
|
||||||
|
channel_name: item.name,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const deptOptions = computed<DeptOption[]>(() => {
|
const deptOptions = computed<DeptOption[]>(() => {
|
||||||
const options = pager.extend.dept_options
|
const options = pager.extend.dept_options
|
||||||
return Array.isArray(options) ? options : []
|
return Array.isArray(options) ? options : []
|
||||||
@@ -177,6 +245,17 @@ const deptOptions = computed<DeptOption[]>(() => {
|
|||||||
|
|
||||||
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
|
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
|
||||||
|
|
||||||
|
const getChannelGroupLabel = (channelCode: string): string => {
|
||||||
|
if (!channelCode) return ''
|
||||||
|
for (const group of mediaChannelGroups.value) {
|
||||||
|
const channel = group.channels.find(ch => ch.channel_code === channelCode)
|
||||||
|
if (channel) {
|
||||||
|
return group.group_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const treeProps = {
|
const treeProps = {
|
||||||
value: 'id',
|
value: 'id',
|
||||||
label: 'name',
|
label: 'name',
|
||||||
@@ -203,13 +282,40 @@ const handleDelete = async (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
queryParams.start_date = ''
|
resetTimeFilter()
|
||||||
queryParams.end_date = ''
|
|
||||||
queryParams.media_channel_code = ''
|
queryParams.media_channel_code = ''
|
||||||
queryParams.dept_id = ''
|
queryParams.dept_id = ''
|
||||||
queryParams.remark = ''
|
queryParams.remark = ''
|
||||||
resetPage()
|
pager.page = 1
|
||||||
|
pager.size = 15
|
||||||
|
getLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
getLists()
|
onMounted(() => {
|
||||||
|
getLists()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-cost-channel-select {
|
||||||
|
:deep(.channel-opt-row) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
:deep(.opt-name) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -174,9 +174,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="支付方式" width="100">
|
<el-table-column label="支付方式" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ getPaymentMethodText(row.payment_method) }}
|
{{ getCreateTypeText(row) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@
|
|||||||
<span class="text-red-500 font-semibold">¥{{ detailData.amount }}</span>
|
<span class="text-red-500 font-semibold">¥{{ detailData.amount }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="支付方式">
|
<el-descriptions-item label="支付方式">
|
||||||
{{ getPaymentMethodText(detailData.payment_method) }}
|
{{ getCreateTypeText(detailData) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="支付时间">
|
<el-descriptions-item label="支付时间">
|
||||||
{{ detailData.payment_time || '-' }}
|
{{ detailData.payment_time || '-' }}
|
||||||
@@ -1148,7 +1148,8 @@ const submitCreateOrder = async () => {
|
|||||||
patient_id: createForm.patient_id,
|
patient_id: createForm.patient_id,
|
||||||
order_type: createForm.order_type,
|
order_type: createForm.order_type,
|
||||||
amount: createForm.amount,
|
amount: createForm.amount,
|
||||||
remark: createForm.remark
|
remark: createForm.remark,
|
||||||
|
create_type: 'wechat_work'
|
||||||
}
|
}
|
||||||
const res: any = await orderCreateForWechatWork(params)
|
const res: any = await orderCreateForWechatWork(params)
|
||||||
const orderNo = res?.order_no ?? res?.data?.order_no
|
const orderNo = res?.order_no ?? res?.data?.order_no
|
||||||
@@ -1160,7 +1161,8 @@ const submitCreateOrder = async () => {
|
|||||||
patient_id: createForm.patient_id,
|
patient_id: createForm.patient_id,
|
||||||
order_type: createForm.order_type,
|
order_type: createForm.order_type,
|
||||||
amount: createForm.amount,
|
amount: createForm.amount,
|
||||||
remark: createForm.remark
|
remark: createForm.remark,
|
||||||
|
create_type: createForm.createType
|
||||||
}
|
}
|
||||||
if (createForm.createType === 'fubei') {
|
if (createForm.createType === 'fubei') {
|
||||||
params.payment_channel = 'fubei'
|
params.payment_channel = 'fubei'
|
||||||
@@ -1247,6 +1249,26 @@ const getPaymentMethodText = (method: string | null) => {
|
|||||||
return methodMap[method] || '未知'
|
return methodMap[method] || '未知'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支付方式展示:优先按「创建方式」三值显示;历史单/无 create_type 时回退真实支付渠道文案
|
||||||
|
const getCreateTypeText = (row: any) => {
|
||||||
|
const createTypeMap: Record<string, string> = {
|
||||||
|
normal: '普通订单',
|
||||||
|
wechat_work: '企业微信对外收款',
|
||||||
|
fubei: '付呗'
|
||||||
|
}
|
||||||
|
const ct = row?.create_type
|
||||||
|
if (ct === 'wechat_work' || ct === 'fubei') {
|
||||||
|
return createTypeMap[ct]
|
||||||
|
}
|
||||||
|
if (ct === 'normal' && row?.payment_method) {
|
||||||
|
return getPaymentMethodText(row.payment_method)
|
||||||
|
}
|
||||||
|
if (ct && createTypeMap[ct]) {
|
||||||
|
return createTypeMap[ct]
|
||||||
|
}
|
||||||
|
return getPaymentMethodText(row?.payment_method)
|
||||||
|
}
|
||||||
|
|
||||||
// 编辑订单
|
// 编辑订单
|
||||||
const handleEditOrder = (row: any) => {
|
const handleEditOrder = (row: any) => {
|
||||||
editOrderForm.value = {
|
editOrderForm.value = {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="note-timeline-wrap">
|
<div class="note-timeline-wrap">
|
||||||
<div v-if="!readonly && diagnosisId" class="timeline-actions">
|
<div v-if="!readonly && diagnosisId" class="timeline-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="hasAddDoctorNotePermission"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
@click="showNoteDialog = true"
|
||||||
|
>
|
||||||
|
添加备注
|
||||||
|
</el-button>
|
||||||
<material-picker
|
<material-picker
|
||||||
v-model="tongueModel"
|
v-model="tongueModel"
|
||||||
:limit="99"
|
:limit="99"
|
||||||
@@ -95,16 +104,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!notes.length && readonly" description="暂无备注" :image-size="48" />
|
<el-empty v-if="!notes.length && readonly" description="暂无备注" :image-size="48" />
|
||||||
|
|
||||||
|
<el-dialog v-model="showNoteDialog" title="添加备注" width="480px" append-to-body>
|
||||||
|
<el-input
|
||||||
|
v-model="noteContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="输入今日备注..."
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showNoteDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="noteSaving" @click="handleSaveNote">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { CircleClose, Document } from '@element-plus/icons-vue'
|
import { CircleClose, Document } from '@element-plus/icons-vue'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import useAppStore from '@/stores/modules/app'
|
import useAppStore from '@/stores/modules/app'
|
||||||
import { addDoctorNote, deleteDoctorNoteImage } from '@/api/patient'
|
import { addDoctorNote, deleteDoctorNoteImage } from '@/api/patient'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
export interface DoctorNoteItem {
|
export interface DoctorNoteItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -122,6 +147,13 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{ refresh: [] }>()
|
const emit = defineEmits<{ refresh: [] }>()
|
||||||
|
|
||||||
|
/** 与接诊台悬浮「备注」按钮一致 */
|
||||||
|
const hasAddDoctorNotePermission = computed(() => hasPermission(['doctor.appointment/addDoctorNote']))
|
||||||
|
|
||||||
|
const showNoteDialog = ref(false)
|
||||||
|
const noteContent = ref('')
|
||||||
|
const noteSaving = ref(false)
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
||||||
|
|
||||||
@@ -201,6 +233,30 @@ watch(() => props.notes, () => {
|
|||||||
prevReportCount.value = 0
|
prevReportCount.value = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleSaveNote = async () => {
|
||||||
|
if (!props.diagnosisId) {
|
||||||
|
feedback.msgWarning('当前无诊单,无法添加备注')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = noteContent.value.trim()
|
||||||
|
if (!text) {
|
||||||
|
feedback.msgWarning('请输入备注内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteSaving.value = true
|
||||||
|
try {
|
||||||
|
await addDoctorNote({ diagnosis_id: props.diagnosisId, content: text })
|
||||||
|
feedback.msgSuccess('备注保存成功')
|
||||||
|
noteContent.value = ''
|
||||||
|
showNoteDialog.value = false
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e: any) {
|
||||||
|
feedback.msgError(e?.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
noteSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'report_files', imagePath: string) => {
|
const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'report_files', imagePath: string) => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' })
|
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' })
|
||||||
@@ -217,6 +273,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
|
|||||||
.timeline-actions {
|
.timeline-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<div class="assistant-performance-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="stats-filter-form">
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||||
|
<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">最近30天</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
style="width: 260px"
|
||||||
|
@change="handleCustomDateChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="fetchData">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="stats-kpi-grid">
|
||||||
|
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card" :class="card.cardClass">
|
||||||
|
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||||
|
{{ formatValue(card.key, card.type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">业绩趋势</span>
|
||||||
|
<span class="card-hint">{{ dateRangeText }} · 履约完成业绩</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<v-charts
|
||||||
|
v-if="chartHasData"
|
||||||
|
class="stats-chart"
|
||||||
|
:option="chartOption"
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
|
<el-empty v-else description="暂无数据" />
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="assistantPerformancePage">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import vCharts from 'vue-echarts'
|
||||||
|
import { assistantPerformanceOverview } from '@/api/stats'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
const queryParams = reactive({
|
||||||
|
time_type: 'month',
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const overview = reactive<Record<string, any>>({
|
||||||
|
date_range: [],
|
||||||
|
summary: {
|
||||||
|
total_amount: 0,
|
||||||
|
total_count: 0
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
dates: [],
|
||||||
|
amounts: [],
|
||||||
|
counts: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryCards = [
|
||||||
|
{ key: 'total_amount', label: '业绩', type: 'money', cardClass: '' },
|
||||||
|
{ key: 'total_count', label: '有效订单数', type: 'count', cardClass: '' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const dateRangeText = computed(() => {
|
||||||
|
if (!overview.date_range?.length) return '未选择'
|
||||||
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartHasData = computed(() => {
|
||||||
|
return overview.chart.dates.length > 0 && overview.chart.amounts.some((v: number) => v > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOption = computed(() => ({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const p = params[0]
|
||||||
|
return `${p.axisValue}<br/>${p.marker}${p.seriesName}: ¥${Number(p.value).toFixed(2)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { left: 60, right: 24, top: 36, bottom: 36 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: overview.chart.dates,
|
||||||
|
axisLabel: {
|
||||||
|
interval: overview.chart.dates.length > 15 ? 'auto' : 0,
|
||||||
|
rotate: overview.chart.dates.length > 10 ? 30 : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '金额(元)',
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (val: number) => val >= 10000 ? (val / 10000).toFixed(1) + '万' : String(val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '业绩',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 6,
|
||||||
|
lineStyle: { width: 3, color: '#4a78ff' },
|
||||||
|
itemStyle: { color: '#4a78ff' },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(74, 120, 255, 0.25)' },
|
||||||
|
{ offset: 1, color: 'rgba(74, 120, 255, 0.02)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: overview.chart.amounts
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const formatValue = (key: string, type: string) => {
|
||||||
|
const value = overview.summary?.[key] ?? 0
|
||||||
|
if (type === 'money') return `¥${Number(value).toFixed(2)}`
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { time_type: queryParams.time_type }
|
||||||
|
if (queryParams.time_type === 'custom') {
|
||||||
|
params.start_date = dateRange.value[0] || ''
|
||||||
|
params.end_date = dateRange.value[1] || ''
|
||||||
|
}
|
||||||
|
const res = await assistantPerformanceOverview(params)
|
||||||
|
Object.assign(overview, res || {})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取业绩数据失败:', error)
|
||||||
|
ElMessage.error(error?.msg || '获取业绩数据失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeTypeChange = () => {
|
||||||
|
if (queryParams.time_type !== 'custom') {
|
||||||
|
dateRange.value = []
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomDateChange = () => {
|
||||||
|
if (dateRange.value?.length === 2) {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.time_type = 'month'
|
||||||
|
dateRange.value = []
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.assistant-performance-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card.is-cancelled {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #fff5f5);
|
||||||
|
border-color: #ffe0e0;
|
||||||
|
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value.is-money {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card.is-cancelled .stats-kpi-value {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-chart {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
<template>
|
||||||
|
<div class="revisit-rate-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="rate-filter-form">
|
||||||
|
<el-form-item label="统计月份">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="month"
|
||||||
|
type="month"
|
||||||
|
value-format="YYYY-MM"
|
||||||
|
placeholder="选择月份"
|
||||||
|
:clearable="false"
|
||||||
|
:disabled-date="disableFutureMonth"
|
||||||
|
style="width: 160px"
|
||||||
|
@change="loadData"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="deptId"
|
||||||
|
:data="deptTreeOptions"
|
||||||
|
placeholder="全部部门"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
check-strictly
|
||||||
|
node-key="id"
|
||||||
|
:default-expand-all="true"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
style="width: 220px"
|
||||||
|
@change="loadData"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||||
|
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
||||||
|
<template #reference>
|
||||||
|
<span class="rate-caliber-trigger">
|
||||||
|
<el-icon><InfoFilled /></el-icon>
|
||||||
|
统计口径
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div class="rate-caliber">
|
||||||
|
<p>
|
||||||
|
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序的全局序号,<b>跨月累计不重置</b>——如 5 月指派后旗下成交 4 单为二诊~五诊,下月再成交即为六诊。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
医助按人事部门归组;选定部门时含其组织下级。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="rate-kpi-grid">
|
||||||
|
<div
|
||||||
|
v-for="card in summaryCards"
|
||||||
|
:key="card.key"
|
||||||
|
class="rate-kpi-card"
|
||||||
|
:class="{ 'is-clickable': card.clickable }"
|
||||||
|
@click="card.clickable && card.onClick && card.onClick()"
|
||||||
|
>
|
||||||
|
<div class="rate-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="rate-kpi-value">{{ card.value }}</div>
|
||||||
|
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="rate-card-header">
|
||||||
|
<span>部门 · 医助明细({{ monthText }})</span>
|
||||||
|
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="rows"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
row-key="row_key"
|
||||||
|
:tree-props="{ children: 'children' }"
|
||||||
|
:default-expand-all="true"
|
||||||
|
show-summary
|
||||||
|
:summary-method="getSummaries"
|
||||||
|
>
|
||||||
|
<el-table-column label="部门 / 医助" min-width="200" fixed>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.is_dept" class="rate-dept-cell">
|
||||||
|
{{ row.dept_name }}
|
||||||
|
<span class="rate-dept-count">{{ row.assistant_count }} 人</span>
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ row.assistant_name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row)"
|
||||||
|
>
|
||||||
|
{{ row.assigned_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.assigned_count }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template v-for="slot in visitSlots" :key="slot">
|
||||||
|
<el-table-column
|
||||||
|
:prop="`visit${slot}_count`"
|
||||||
|
:label="`${slotLabel(slot)}单数`"
|
||||||
|
min-width="96"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ slotLabel(slot) }}单数</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
:content="`当月内下单、诊次为第 ${slot} 次的订单数(诊次跨月累计;归属下单时点持有患者的医助)`"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderDetail(slot, row)"
|
||||||
|
>
|
||||||
|
{{ row[`visit${slot}_count`] }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row[`visit${slot}_count`] }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
:prop="`visit${slot}_rate`"
|
||||||
|
:label="`${slotLabel(slot)}接诊率`"
|
||||||
|
min-width="96"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 下钻明细 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailVisible"
|
||||||
|
:title="detailTitle"
|
||||||
|
width="980px"
|
||||||
|
top="6vh"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-table
|
||||||
|
v-loading="detailLoading"
|
||||||
|
:data="detailRows"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
max-height="60vh"
|
||||||
|
>
|
||||||
|
<template v-if="detailType === 'assign'">
|
||||||
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openPatientDetail(row.diagnosis_id)"
|
||||||
|
>
|
||||||
|
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="patient_phone" label="联系电话" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||||
|
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderDetail && row.order_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderInfo(row.order_id)"
|
||||||
|
>
|
||||||
|
{{ row.order_no || `#${row.order_id}` }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openPatientDetail(row.diagnosis_id)"
|
||||||
|
>
|
||||||
|
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="amount" label="金额" width="100" align="right">
|
||||||
|
<template #default="{ row }">¥ {{ row.amount }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
|
||||||
|
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
|
||||||
|
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<div class="rate-detail-footer">共 {{ detailRows.length }} 条</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 订单详情抽屉(共享组件,与处方订单列表详情一致;只读) -->
|
||||||
|
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
|
||||||
|
|
||||||
|
<!-- 诊单(患者)详情:编辑页只读模式 -->
|
||||||
|
<diagnosis-edit-popup ref="diagnosisEditRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="revisitRateStatsPage">
|
||||||
|
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
revisitRateAssignLines,
|
||||||
|
revisitRateDeptOptions,
|
||||||
|
revisitRateOverview,
|
||||||
|
revisitRateVisitOrderLines
|
||||||
|
} from '@/api/stats'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
|
/** 业务订单详情抽屉(共享组件,readonly 模式,与 order_list.vue 详情同源) */
|
||||||
|
const OrderDetailDrawer = defineAsyncComponent(
|
||||||
|
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
|
||||||
|
)
|
||||||
|
/** 诊单编辑弹窗(只读打开) */
|
||||||
|
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
|
interface MetricPack extends Record<string, any> {
|
||||||
|
assigned_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsRow extends MetricPack {
|
||||||
|
row_key: string
|
||||||
|
is_dept: 0 | 1
|
||||||
|
dept_id: number
|
||||||
|
dept_name: string
|
||||||
|
assistant_id?: number
|
||||||
|
assistant_name?: string
|
||||||
|
assistant_count?: number
|
||||||
|
children?: StatsRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeptTreeNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
pid: number
|
||||||
|
children?: DeptTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const canViewAssignLines = hasPermission(['stats.revisitRate/assignLines'])
|
||||||
|
const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
||||||
|
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||||
|
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||||
|
|
||||||
|
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
||||||
|
const visitSlots = ref<number[]>([2, 3, 4])
|
||||||
|
|
||||||
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot >= 2 && slot <= 10) {
|
||||||
|
return `${cnDigits[slot]}诊`
|
||||||
|
}
|
||||||
|
if (slot > 10 && slot < 20) {
|
||||||
|
return `十${cnDigits[slot - 10]}诊`
|
||||||
|
}
|
||||||
|
return `${slot}诊`
|
||||||
|
}
|
||||||
|
|
||||||
|
const initNow = new Date()
|
||||||
|
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const month = ref<string>(currentMonth)
|
||||||
|
const deptId = ref<number | undefined>(undefined)
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<StatsRow[]>([])
|
||||||
|
const total = ref<MetricPack | null>(null)
|
||||||
|
|
||||||
|
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||||
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const monthText = computed(() => {
|
||||||
|
const [y, m] = month.value.split('-')
|
||||||
|
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
const baseQuery = () => ({
|
||||||
|
month: month.value,
|
||||||
|
dept_ids: deptId.value ? String(deptId.value) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
interface SummaryCard {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sub: string
|
||||||
|
clickable: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryCards = computed<SummaryCard[]>(() => {
|
||||||
|
const t = total.value
|
||||||
|
const cards: SummaryCard[] = [
|
||||||
|
{
|
||||||
|
key: 'assigned',
|
||||||
|
label: '当月被指派总数',
|
||||||
|
value: t ? String(t.assigned_count) : '—',
|
||||||
|
sub: '',
|
||||||
|
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||||
|
onClick: () => openAssignDetail(null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
for (const slot of visitSlots.value) {
|
||||||
|
const cnt = t ? t[`visit${slot}_count`] : null
|
||||||
|
const rate = t ? t[`visit${slot}_rate`] : null
|
||||||
|
cards.push({
|
||||||
|
key: `visit${slot}`,
|
||||||
|
label: `当月${slotLabel(slot)}接诊率`,
|
||||||
|
value: t ? formatRate(rate) : '—',
|
||||||
|
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
||||||
|
clickable: !!(canViewOrderLines && cnt > 0),
|
||||||
|
onClick: () => openOrderDetail(slot, null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 底栏合计:直接展示后端按诊单去重后的合计,不做各行求和 */
|
||||||
|
const getSummaries = ({ columns }: { columns: any[] }) => {
|
||||||
|
const t = total.value
|
||||||
|
return columns.map((col, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return '合计'
|
||||||
|
}
|
||||||
|
if (!t) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const prop = String(col.property || '')
|
||||||
|
if (prop === 'assigned_count') {
|
||||||
|
return String(t.assigned_count)
|
||||||
|
}
|
||||||
|
if (/^visit\d+_count$/.test(prop)) {
|
||||||
|
return String(t[prop] ?? '')
|
||||||
|
}
|
||||||
|
if (/^visit\d+_rate$/.test(prop)) {
|
||||||
|
return formatRate(t[prop])
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableFutureMonth = (date: Date) => {
|
||||||
|
const now = new Date()
|
||||||
|
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await revisitRateOverview(baseQuery())
|
||||||
|
rows.value = res?.rows ?? []
|
||||||
|
total.value = res?.total ?? null
|
||||||
|
if (Array.isArray(res?.slots) && res.slots.length) {
|
||||||
|
visitSlots.value = res.slots.map((s: any) => Number(s)).filter((s: number) => s >= 2)
|
||||||
|
}
|
||||||
|
if (res?.month) {
|
||||||
|
month.value = res.month
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||||
|
const map = new Map<number, DeptTreeNode>()
|
||||||
|
for (const d of flat) {
|
||||||
|
map.set(d.id, { ...d, children: [] })
|
||||||
|
}
|
||||||
|
const roots: DeptTreeNode[] = []
|
||||||
|
for (const d of flat) {
|
||||||
|
const node = map.get(d.id)!
|
||||||
|
if (d.pid > 0 && map.has(d.pid)) {
|
||||||
|
map.get(d.pid)!.children!.push(node)
|
||||||
|
} else {
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strip = (n: DeptTreeNode) => {
|
||||||
|
if (n.children?.length) {
|
||||||
|
n.children.forEach(strip)
|
||||||
|
} else {
|
||||||
|
delete n.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.forEach(strip)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await revisitRateDeptOptions()
|
||||||
|
deptTreeOptions.value = buildDeptTree(res?.rows ?? [])
|
||||||
|
} catch {
|
||||||
|
deptTreeOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 下钻明细 ──────────────────────────────────────────
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailType = ref<'assign' | 'orders'>('assign')
|
||||||
|
const detailTitle = ref('')
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailRows = ref<any[]>([])
|
||||||
|
|
||||||
|
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||||
|
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||||
|
if (!row) {
|
||||||
|
return { label: '合计' }
|
||||||
|
}
|
||||||
|
if (row.is_dept) {
|
||||||
|
return { dept_id: row.dept_id, label: row.dept_name }
|
||||||
|
}
|
||||||
|
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAssignDetail = async (row: StatsRow | null) => {
|
||||||
|
if (!canViewAssignLines) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scope = rowScope(row)
|
||||||
|
detailType.value = 'assign'
|
||||||
|
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
detailRows.value = []
|
||||||
|
try {
|
||||||
|
const res = await revisitRateAssignLines({
|
||||||
|
...baseQuery(),
|
||||||
|
assistant_id: scope.assistant_id,
|
||||||
|
dept_id: scope.dept_id
|
||||||
|
})
|
||||||
|
detailRows.value = res?.rows ?? []
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||||
|
if (!canViewOrderLines) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scope = rowScope(row)
|
||||||
|
detailType.value = 'orders'
|
||||||
|
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
detailRows.value = []
|
||||||
|
try {
|
||||||
|
const res = await revisitRateVisitOrderLines({
|
||||||
|
...baseQuery(),
|
||||||
|
slot,
|
||||||
|
assistant_id: scope.assistant_id,
|
||||||
|
dept_id: scope.dept_id
|
||||||
|
})
|
||||||
|
detailRows.value = res?.rows ?? []
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 订单详情(复用处方订单列表的详情抽屉) ──────────────
|
||||||
|
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
|
||||||
|
|
||||||
|
const openOrderInfo = (orderId: number) => {
|
||||||
|
if (!canViewOrderDetail || !orderId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderDetailRef.value?.open(orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 患者(诊单)详情:诊单编辑页只读打开 ────────────────
|
||||||
|
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
|
||||||
|
|
||||||
|
const openPatientDetail = (diagnosisId: number) => {
|
||||||
|
if (!canViewPatientDetail || !diagnosisId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
diagnosisEditRef.value?.openViewOnly(diagnosisId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
month.value = currentMonth
|
||||||
|
deptId.value = undefined
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDeptOptions()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.revisit-rate-page {
|
||||||
|
.rate-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-caliber-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-card {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
|
||||||
|
&.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-label {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.rate-card-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-dept-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.rate-dept-count {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-th-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-detail-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-caliber {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
|
||||||
|
p + p {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<el-select
|
||||||
|
:model-value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:clearable="clearable"
|
||||||
|
:filterable="filterable"
|
||||||
|
:allow-create="allowCreate"
|
||||||
|
:default-first-option="allowCreate"
|
||||||
|
:disabled="disabled"
|
||||||
|
:loading="loading"
|
||||||
|
:class="selectClass"
|
||||||
|
:style="selectStyle"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
@change="emit('change', $event)"
|
||||||
|
@visible-change="onVisibleChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in normalizedOptions"
|
||||||
|
:key="item.name"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.name"
|
||||||
|
/>
|
||||||
|
<!-- 旧记录可能存在不在当前字典内的值:保留显示,避免编辑时反查不到 -->
|
||||||
|
<el-option
|
||||||
|
v-if="modelValue && !hasCurrentValue"
|
||||||
|
:key="`__legacy_${modelValue}`"
|
||||||
|
:label="`${modelValue}(已停用)`"
|
||||||
|
:value="modelValue"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
interface OptionItem {
|
||||||
|
name: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
options: Array<OptionItem | string>
|
||||||
|
loading?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
clearable?: boolean
|
||||||
|
filterable?: boolean
|
||||||
|
allowCreate?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
selectClass?: string
|
||||||
|
selectStyle?: string | Record<string, string>
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
loading: false,
|
||||||
|
placeholder: '请选择自媒体来源',
|
||||||
|
clearable: true,
|
||||||
|
filterable: true,
|
||||||
|
allowCreate: false,
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
change: [value: string]
|
||||||
|
'visible-change': [visible: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const normalizedOptions = computed<OptionItem[]>(() => {
|
||||||
|
return (props.options || []).map((item) =>
|
||||||
|
typeof item === 'string' ? { name: item } : { name: item.name, value: item.value }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCurrentValue = computed(() => {
|
||||||
|
return normalizedOptions.value.some((item) => item.name === props.modelValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onVisibleChange = (visible: boolean) => {
|
||||||
|
emit('visible-change', visible)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<div class="personal-account-cost-page">
|
||||||
|
<el-card class="!border-none mb-4" shadow="never">
|
||||||
|
<div class="flex flex-wrap">
|
||||||
|
<div class="w-1/2 md:w-1/3">
|
||||||
|
<div class="leading-10">当前筛选天数</div>
|
||||||
|
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2 md:w-1/3">
|
||||||
|
<div class="leading-10">累计账户消耗 (元)</div>
|
||||||
|
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||||
|
<list-time-filter
|
||||||
|
v-model:time-type="time_type"
|
||||||
|
v-model:date-range="dateRange"
|
||||||
|
@time-type-change="onTimeTypeChange"
|
||||||
|
@custom-change="onCustomDateChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptOptions"
|
||||||
|
node-key="id"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
:default-expand-all="true"
|
||||||
|
check-strictly
|
||||||
|
placeholder="全部部门"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
class="w-[220px]"
|
||||||
|
@change="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源">
|
||||||
|
<media-source-select
|
||||||
|
v-model="queryParams.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="全部来源"
|
||||||
|
:allow-create="false"
|
||||||
|
select-class="w-[220px]"
|
||||||
|
@change="handleQuery"
|
||||||
|
@visible-change="onMediaSourceDropdownVisible"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="w-[280px]" label="备注/录入人">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.remark"
|
||||||
|
placeholder="备注 / 创建人"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<div>
|
||||||
|
<el-button v-perms="['stats.personal_account_cost/add']" type="primary" @click="handleAdd">
|
||||||
|
<template #icon>
|
||||||
|
<icon name="el-icon-Plus" />
|
||||||
|
</template>
|
||||||
|
新增
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||||
|
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||||
|
<el-table-column label="自媒体来源" prop="media_source" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="账户消耗" min-width="120">
|
||||||
|
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||||
|
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.dept_path"
|
||||||
|
:content="row.dept_path"
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||||
|
<span v-else class="text-gray-400">未分配</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
|
||||||
|
<el-table-column label="更新时间" prop="update_time" min-width="180" />
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_account_cost/edit']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_account_cost/delete']"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="handleDelete(row.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<cost-edit-popup
|
||||||
|
v-if="showEdit"
|
||||||
|
ref="editRef"
|
||||||
|
:media-source-options="mediaSourceOptions"
|
||||||
|
:media-source-loading="mediaSourceLoading"
|
||||||
|
@success="handleCostSuccess"
|
||||||
|
@close="showEdit = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup name="personalAccountCost">
|
||||||
|
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
|
||||||
|
import { deptAll } from '@/api/org/department'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { getRoutePath } from '@/router'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
import CostEditPopup from './cost-edit.vue'
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||||
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const deptOptions = ref<any[]>([])
|
||||||
|
const {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
} = useMediaSourceOptions()
|
||||||
|
|
||||||
|
const deptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children',
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
|
||||||
|
|
||||||
|
const goSelfInput = () => {
|
||||||
|
if (selfInputPath.value) {
|
||||||
|
router.push(selfInputPath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
media_source: '',
|
||||||
|
dept_id: undefined as number | undefined,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
time_type,
|
||||||
|
dateRange,
|
||||||
|
resolve,
|
||||||
|
handleTimeTypeChange,
|
||||||
|
handleCustomDateChange,
|
||||||
|
resetTimeFilter,
|
||||||
|
} = useListTimeFilter('today')
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage } = usePaging({
|
||||||
|
fetchFun: async (params: Record<string, any>) => {
|
||||||
|
const [start_date, end_date] = resolve()
|
||||||
|
return personalAccountCostLists({
|
||||||
|
...params,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
params: queryParams,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
pager.page = 1
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
|
||||||
|
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
resetTimeFilter()
|
||||||
|
queryParams.media_source = ''
|
||||||
|
queryParams.dept_id = undefined
|
||||||
|
queryParams.remark = ''
|
||||||
|
pager.page = 1
|
||||||
|
pager.size = 15
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||||
|
if (visible) {
|
||||||
|
refreshMediaSourceOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCostSuccess = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
showEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
editRef.value?.open('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = async (row: Record<string, any>) => {
|
||||||
|
showEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
editRef.value?.open('edit')
|
||||||
|
editRef.value?.setFormData(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await feedback.confirm('确定删除该账户消耗记录?')
|
||||||
|
await personalAccountCostDelete({ id })
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await deptAll({ apply_data_scope: 1 })
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
deptOptions.value = res
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deptOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadDeptOptions()
|
||||||
|
loadMediaSourceOptions()
|
||||||
|
await getLists()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.personal-account-cost-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edit-popup">
|
||||||
|
<popup
|
||||||
|
ref="popupRef"
|
||||||
|
:title="popupTitle"
|
||||||
|
:async="true"
|
||||||
|
width="520px"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||||
|
<el-form-item label="日期" prop="cost_date">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="formData.cost_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="请选择日期"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源" prop="media_source">
|
||||||
|
<media-source-select
|
||||||
|
v-model="formData.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="请选择自媒体来源"
|
||||||
|
:allow-create="false"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
select-class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="账户消耗" prop="amount">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.amount"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.remark"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
personalAccountCostAdd,
|
||||||
|
personalAccountCostEdit,
|
||||||
|
} from '@/api/self_input_stats'
|
||||||
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mediaSourceOptions?: MediaSourceOption[]
|
||||||
|
mediaSourceLoading?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mediaSourceOptions: () => [],
|
||||||
|
mediaSourceLoading: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'close'])
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||||
|
const mode = ref<'add' | 'edit'>('add')
|
||||||
|
|
||||||
|
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: '',
|
||||||
|
cost_date: '',
|
||||||
|
media_source: '',
|
||||||
|
amount: 0,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
cost_date: [{ required: true, message: '请选择日期', trigger: ['change'] }],
|
||||||
|
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||||
|
amount: [{ required: true, message: '请输入账户消耗金额', trigger: ['blur', 'change'] }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.id = ''
|
||||||
|
formData.cost_date = ''
|
||||||
|
formData.media_source = ''
|
||||||
|
formData.amount = 0
|
||||||
|
formData.remark = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
if (mode.value === 'edit') {
|
||||||
|
await personalAccountCostEdit(formData)
|
||||||
|
} else {
|
||||||
|
await personalAccountCostAdd(formData)
|
||||||
|
}
|
||||||
|
popupRef.value?.close()
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = (type: 'add' | 'edit' = 'add') => {
|
||||||
|
mode.value = type
|
||||||
|
resetForm()
|
||||||
|
popupRef.value?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFormData = (data: Record<string, any>) => {
|
||||||
|
formData.id = data.id ?? ''
|
||||||
|
formData.cost_date = data.cost_date ?? ''
|
||||||
|
formData.media_source = data.media_source ?? ''
|
||||||
|
formData.amount = Number(data.amount ?? 0)
|
||||||
|
formData.remark = data.remark ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setFormData,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
<template>
|
||||||
|
<div class="self-input-stats-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||||
|
<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">最近30天</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
style="width: 260px"
|
||||||
|
@change="handleCustomDateChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptOptions"
|
||||||
|
node-key="id"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
:default-expand-all="true"
|
||||||
|
check-strictly
|
||||||
|
placeholder="全部部门"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 220px"
|
||||||
|
@change="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="自媒体来源">
|
||||||
|
<media-source-select
|
||||||
|
v-model="queryParams.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="全部来源"
|
||||||
|
:allow-create="false"
|
||||||
|
select-style="width: 220px"
|
||||||
|
@change="handleQuery"
|
||||||
|
@visible-change="onMediaSourceDropdownVisible"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="stats-kpi-grid">
|
||||||
|
<div v-for="card in visibleSummaryCards" :key="card.key" class="stats-kpi-card">
|
||||||
|
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||||
|
{{ renderMetric(card.key, overview.summary, card.type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4 stats-detail-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-main">
|
||||||
|
<span class="card-title">业绩明细</span>
|
||||||
|
<span class="card-hint">{{ dateRangeText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-header-actions">
|
||||||
|
<el-button v-perms="['stats.personal_yeji/add']" type="primary" @click="handleAddYeji">
|
||||||
|
<template #icon>
|
||||||
|
<icon name="el-icon-Plus" />
|
||||||
|
</template>
|
||||||
|
新增业绩
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="accountCostEntryPath"
|
||||||
|
v-perms="['stats.personal_account_cost/lists']"
|
||||||
|
@click="goAccountCostEntry"
|
||||||
|
>
|
||||||
|
账户消耗录入
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="pager.loading"
|
||||||
|
:data="pager.lists"
|
||||||
|
border
|
||||||
|
size="large"
|
||||||
|
max-height="640"
|
||||||
|
>
|
||||||
|
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
|
||||||
|
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||||
|
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.dept_path"
|
||||||
|
:content="row.dept_path"
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||||
|
<span v-else class="text-gray-400">未分配</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-for="col in visibleTableColumns"
|
||||||
|
:key="col.key"
|
||||||
|
:label="col.label"
|
||||||
|
:min-width="col.minWidth || 100"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ renderMetric(col.key, row, col.type) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_yeji/edit']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleEditYeji(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['stats.personal_yeji/delete']"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="handleDeleteYeji(row.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="fetchOverview" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<yeji-edit-popup
|
||||||
|
v-if="showYejiEdit"
|
||||||
|
ref="yejiEditRef"
|
||||||
|
:media-source-options="mediaSourceOptions"
|
||||||
|
:media-source-loading="mediaSourceLoading"
|
||||||
|
@success="handleYejiSuccess"
|
||||||
|
@close="showYejiEdit = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup name="selfInputStats">
|
||||||
|
import {
|
||||||
|
getSelfInputOverview,
|
||||||
|
personalYejiDelete,
|
||||||
|
} from '@/api/self_input_stats'
|
||||||
|
import { deptAll } from '@/api/org/department'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { getRoutePath } from '@/router'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import YejiEditPopup from './yeji-edit.vue'
|
||||||
|
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
|
||||||
|
|
||||||
|
interface MetricCard {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: MetricType
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: MetricType
|
||||||
|
minWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
|
||||||
|
const showYejiEdit = ref(false)
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
const deptOptions = ref<any[]>([])
|
||||||
|
const {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
} = useMediaSourceOptions()
|
||||||
|
|
||||||
|
const deptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children',
|
||||||
|
}
|
||||||
|
|
||||||
|
const overview = reactive<Record<string, any>>({
|
||||||
|
date_range: [],
|
||||||
|
summary: {},
|
||||||
|
can_view_finance: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi'])
|
||||||
|
const canViewFinance = computed(() => Boolean(overview.can_view_finance))
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
media_source: '',
|
||||||
|
dept_id: undefined as number | undefined,
|
||||||
|
time_type: 'today',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
|
||||||
|
|
||||||
|
const goAccountCostEntry = () => {
|
||||||
|
if (accountCostEntryPath.value) {
|
||||||
|
router.push(accountCostEntryPath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOverviewList = async (params: Record<string, any>) => {
|
||||||
|
if (queryParams.time_type === 'custom') {
|
||||||
|
params.start_date = dateRange.value[0] || ''
|
||||||
|
params.end_date = dateRange.value[1] || ''
|
||||||
|
}
|
||||||
|
const res = await getSelfInputOverview(params)
|
||||||
|
Object.assign(overview, res?.extend || {})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await deptAll({ apply_data_scope: 1 })
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
deptOptions.value = res
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
deptOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||||
|
if (visible) {
|
||||||
|
refreshMediaSourceOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleYejiSuccess = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
await fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDeptOptions()
|
||||||
|
loadMediaSourceOptions()
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pager, getLists } = usePaging({
|
||||||
|
fetchFun: fetchOverviewList,
|
||||||
|
params: queryParams,
|
||||||
|
firstLoading: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryCards: MetricCard[] = [
|
||||||
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
|
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||||
|
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||||
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||||
|
{ key: 'order_amount', label: '订单金额', type: 'money' },
|
||||||
|
{ key: 'account_cost', label: '账户消耗', type: 'money' },
|
||||||
|
{ key: 'total_open_rate', label: '总开口率', type: 'percent' },
|
||||||
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
|
||||||
|
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent' },
|
||||||
|
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
|
||||||
|
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent' },
|
||||||
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
|
||||||
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
|
||||||
|
{ key: 'cash_cost', label: '现金成本', type: 'money' },
|
||||||
|
{ key: 'roi', label: 'ROI', type: 'ratio' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tableColumns: MetricColumn[] = [
|
||||||
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
|
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||||
|
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||||
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
|
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
|
||||||
|
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
|
||||||
|
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
|
||||||
|
{ key: 'total_open_rate', label: '总开口率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 120 },
|
||||||
|
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', minWidth: 110 },
|
||||||
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 100 },
|
||||||
|
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 100 },
|
||||||
|
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibleSummaryCards = computed(() =>
|
||||||
|
canViewFinance.value
|
||||||
|
? summaryCards
|
||||||
|
: summaryCards.filter((card) => !FINANCE_KEYS.has(card.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleTableColumns = computed(() =>
|
||||||
|
canViewFinance.value
|
||||||
|
? tableColumns
|
||||||
|
: tableColumns.filter((col) => !FINANCE_KEYS.has(col.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const dateRangeText = computed(() => {
|
||||||
|
if (!overview.date_range?.length) return '未选择'
|
||||||
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchOverview = async () => {
|
||||||
|
try {
|
||||||
|
await getLists()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取自录转化统计失败:', error)
|
||||||
|
ElMessage.error(error?.msg || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeTypeChange = () => {
|
||||||
|
if (queryParams.time_type !== 'custom') {
|
||||||
|
dateRange.value = []
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomDateChange = () => {
|
||||||
|
if (dateRange.value.length === 2) {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.media_source = ''
|
||||||
|
queryParams.dept_id = undefined
|
||||||
|
queryParams.time_type = 'today'
|
||||||
|
dateRange.value = []
|
||||||
|
pager.page = 1
|
||||||
|
pager.size = 15
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderMetric = (key: string, source: Record<string, any>, type = 'count') => {
|
||||||
|
const value = source?.[key] ?? 0
|
||||||
|
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||||
|
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||||
|
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||||
|
return String(value ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddYeji = async () => {
|
||||||
|
await refreshMediaSourceOptions()
|
||||||
|
showYejiEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
yejiEditRef.value?.open('add')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditYeji = async (row: Record<string, any>) => {
|
||||||
|
showYejiEdit.value = true
|
||||||
|
await nextTick()
|
||||||
|
yejiEditRef.value?.open('edit')
|
||||||
|
yejiEditRef.value?.setFormData(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteYeji = async (id: number) => {
|
||||||
|
await feedback.confirm('确定删除该业绩记录?')
|
||||||
|
await personalYejiDelete({ id })
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchOverview()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.self-input-stats-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value.is-money {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-detail-card :deep(.el-card__body) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。
|
||||||
|
* 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。
|
||||||
|
*/
|
||||||
|
export interface MediaSourceOption {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMediaSourceOptions() {
|
||||||
|
const mediaSourceOptions = ref<MediaSourceOption[]>([])
|
||||||
|
const mediaSourceLoading = ref(false)
|
||||||
|
|
||||||
|
const normalize = (raw: any): MediaSourceOption[] => {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const list: MediaSourceOption[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const name = item.trim()
|
||||||
|
if (name && !seen.has(name)) {
|
||||||
|
seen.add(name)
|
||||||
|
list.push({ name, value: '' })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
const name = String(item.name ?? '').trim()
|
||||||
|
if (name && !seen.has(name)) {
|
||||||
|
seen.add(name)
|
||||||
|
list.push({ name, value: String(item.value ?? '') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMediaSourceOptions = async (force = false) => {
|
||||||
|
if (!force && mediaSourceOptions.value.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaSourceLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSelfInputMediaSourceOptions()
|
||||||
|
mediaSourceOptions.value = normalize(res)
|
||||||
|
} catch {
|
||||||
|
mediaSourceOptions.value = []
|
||||||
|
} finally {
|
||||||
|
mediaSourceLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaSourceOptions,
|
||||||
|
mediaSourceLoading,
|
||||||
|
loadMediaSourceOptions,
|
||||||
|
refreshMediaSourceOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edit-popup">
|
||||||
|
<popup
|
||||||
|
ref="popupRef"
|
||||||
|
:title="popupTitle"
|
||||||
|
:async="true"
|
||||||
|
width="640px"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||||
|
<el-form-item label="业绩日期" prop="yeji_date">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="formData.yeji_date"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="请选择日期"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自媒体来源" prop="media_source">
|
||||||
|
<media-source-select
|
||||||
|
v-model="formData.media_source"
|
||||||
|
:options="mediaSourceOptions"
|
||||||
|
:loading="mediaSourceLoading"
|
||||||
|
placeholder="请选择自媒体来源"
|
||||||
|
:allow-create="false"
|
||||||
|
:disabled="mode === 'edit'"
|
||||||
|
select-class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="加粉数" prop="add_fans_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.add_fans_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="总开口" prop="total_open_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.total_open_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="未回复" prop="unreplied_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.unreplied_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="付费挂号" prop="paid_appointment_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.paid_appointment_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="免费挂号" prop="free_appointment_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.free_appointment_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="面诊" prop="interview_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.interview_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="订单金额" prop="order_amount">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.order_amount"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="接诊诊单" prop="completed_order_count">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.completed_order_count"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.remark"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
|
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
|
||||||
|
import Popup from '@/components/popup/index.vue'
|
||||||
|
|
||||||
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
|
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mediaSourceOptions?: MediaSourceOption[]
|
||||||
|
mediaSourceLoading?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mediaSourceOptions: () => [],
|
||||||
|
mediaSourceLoading: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'close'])
|
||||||
|
const formRef = shallowRef<FormInstance>()
|
||||||
|
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||||
|
const mode = ref<'add' | 'edit'>('add')
|
||||||
|
|
||||||
|
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑业绩' : '新增业绩'))
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: '',
|
||||||
|
yeji_date: '',
|
||||||
|
media_source: '',
|
||||||
|
add_fans_count: 0,
|
||||||
|
total_open_count: 0,
|
||||||
|
unreplied_count: 0,
|
||||||
|
paid_appointment_count: 0,
|
||||||
|
free_appointment_count: 0,
|
||||||
|
interview_count: 0,
|
||||||
|
order_amount: 0,
|
||||||
|
completed_order_count: 0,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
yeji_date: [{ required: true, message: '请选择业绩日期', trigger: ['change'] }],
|
||||||
|
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||||
|
add_fans_count: [{ required: true, message: '请填写加粉数', trigger: ['blur', 'change'] }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.id = ''
|
||||||
|
formData.yeji_date = ''
|
||||||
|
formData.media_source = ''
|
||||||
|
formData.add_fans_count = 0
|
||||||
|
formData.total_open_count = 0
|
||||||
|
formData.unreplied_count = 0
|
||||||
|
formData.paid_appointment_count = 0
|
||||||
|
formData.free_appointment_count = 0
|
||||||
|
formData.interview_count = 0
|
||||||
|
formData.order_amount = 0
|
||||||
|
formData.completed_order_count = 0
|
||||||
|
formData.remark = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await formRef.value?.validate()
|
||||||
|
if (mode.value === 'edit') {
|
||||||
|
await personalYejiEdit(formData)
|
||||||
|
} else {
|
||||||
|
await personalYejiAdd(formData)
|
||||||
|
}
|
||||||
|
popupRef.value?.close()
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = (type: 'add' | 'edit' = 'add') => {
|
||||||
|
mode.value = type
|
||||||
|
resetForm()
|
||||||
|
popupRef.value?.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFormData = (data: Record<string, any>) => {
|
||||||
|
formData.id = data.id ?? ''
|
||||||
|
formData.yeji_date = data.yeji_date ?? ''
|
||||||
|
formData.media_source = data.media_source ?? ''
|
||||||
|
formData.add_fans_count = Number(data.add_fans_count ?? 0)
|
||||||
|
formData.total_open_count = Number(data.total_open_count ?? 0)
|
||||||
|
formData.unreplied_count = Number(data.unreplied_count ?? 0)
|
||||||
|
formData.paid_appointment_count = Number(data.paid_appointment_count ?? 0)
|
||||||
|
formData.free_appointment_count = Number(data.free_appointment_count ?? 0)
|
||||||
|
formData.interview_count = Number(data.interview_count ?? 0)
|
||||||
|
formData.order_amount = Number(data.order_amount ?? 0)
|
||||||
|
formData.completed_order_count = Number(data.completed_order_count ?? 0)
|
||||||
|
formData.remark = data.remark ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
setFormData,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user