Compare commits

...
Author SHA1 Message Date
大哥大哥的大哥哥 49efa21af3 design(tongji): add complete 3d food icon set 2026-07-27 03:05:18 +08:00
大哥大哥的大哥哥 c8507aed43 feat(tongji): add milk tea afternoon challenge 2026-07-27 01:51:08 +08:00
大哥大哥的大哥哥 87b8949f95 chore(wx): upgrade WechatSI plugin to 0.3.9 2026-07-22 16:48:20 +08:00
大哥大哥的大哥哥 1626b6f498 fix(tongji): stabilize food transitions and mixed weekly rank 2026-07-22 15:21:51 +08:00
大哥大哥的大哥哥 68b5b12b59 feat(tongji): add hidden flow scoring mode 2026-07-21 15:19:06 +08:00
大哥大哥的大哥哥 9e684f0afb fix(tongji): 恢复游戏进度并完善周榜 2026-07-20 19:04:45 +08:00
大哥大哥的大哥哥 8a6890767e fix(tongji): 恢复居家训练并统一识糖入口 2026-07-18 11:30:54 +08:00
大哥大哥的大哥哥 d57e60539e Merge remote-tracking branch 'origin/master' into feature/tongji-endless-game
# Conflicts:
#	TUICallKit-Vue3/tongji/endless-game/README.md
#	TUICallKit-Vue3/tongji/endless-game/index.vue
#	TUICallKit-Vue3/vite.config.ts
2026-07-18 11:24:59 +08:00
大哥大哥的大哥哥 78fd00fa05 fix(miniprogram): 优化分包并清理旧版小游戏 2026-07-18 11:21:12 +08:00
大哥大哥的大哥哥 fd7e6071a8 fix(tongji): 修复小游戏平台同步与并发问题 2026-07-17 21:21:43 +08:00
大哥大哥的大哥哥 7192c4db8c feat(tongji): 接入小游戏真实周榜与微信分享 2026-07-17 18:02:00 +08:00
Your Name b02c340348 更新 2026-07-17 14:47:00 +08:00
Your Name 0f21dd4451 更新 2026-07-17 14:12:15 +08:00
Your Name 1ed3e06fdf 更新 2026-07-17 12:50:11 +08:00
Your Name c608145023 i
Merge branch 'bug-0717'
2026-07-17 12:13:30 +08:00
Your Name 4f95d9182e 更新 2026-07-17 12:13:02 +08:00
long f662723a25 debug 2026-07-17 11:37:05 +08:00
long 32370df7fe feat(tongji): 新增控糖消消乐小游戏 2026-07-17 10:24:42 +08:00
大哥大哥的大哥哥 62467dc01c feat(tongji): 新增控糖消消乐小游戏 2026-07-17 09:30:22 +08:00
Your Name 97fefbae8f 更新 2026-07-16 09:43:07 +08:00
Your Name 541fe59ad1 Merge branch 'xuhao-20260715' 2026-07-16 09:37:30 +08:00
longandCursor b6c5b422c2 test: 空提交验证 Gitea SSH 推送
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 09:25:10 +08:00
Your Name 500038601c 更新 2026-07-16 09:04:40 +08:00
Your Name f6688a740d 更新 2026-07-10 14:19:01 +08:00
Your Name a347e0642c Merge branch 'AI-20260710' 2026-07-10 14:16:51 +08:00
Your Name 13adbac3fd 更新 2026-07-10 14:16:07 +08:00
Your Name 333a18c37e 更新 2026-07-10 14:10:48 +08:00
Your Name e3a91a91f7 i
Merge branch 'AI-20260710'
2026-07-10 14:08:47 +08:00
Your Name 5ca5f06255 更新 2026-07-10 14:08:27 +08:00
Your Name e01f3e7072 更新 2026-07-10 10:58:14 +08:00
Your Name ed78803015 更新 2026-07-10 10:52:36 +08:00
Your Name 546138d60e 更新 2026-07-10 10:28:21 +08:00
Your Name fdddfdb3dd AI 客服 2026-07-10 10:25:51 +08:00
Your Name f5a89b4310 更新 2026-07-09 17:58:02 +08:00
Your Name a650747fd3 更新 2026-07-09 11:27:20 +08:00
Your Name 46b62229a6 i
Merge branch '20260708'
2026-07-09 11:25:25 +08:00
Your Name 0f33f2ad28 更新 2026-07-09 11:24:18 +08:00
Your Name a677b69e97 更新 2026-07-08 15:00:28 +08:00
Your Name 6432e9990c 更新 2026-07-08 14:53:41 +08:00
Your Name 4d6125268a 更新 2026-07-08 14:46:39 +08:00
Your Name 7f93cf5480 更新 2026-07-07 11:06:19 +08:00
Your Name f8f1953a2a Merge branch 'bug-20260706' 2026-07-07 11:04:43 +08:00
Your Name 99eca8b2e5 更新 2026-07-07 09:24:40 +08:00
Your Name bde396189a Please enter the commit message for your changes. Lines starting 2026-07-06 16:58:01 +08:00
Your Name 55e3b5d9e0 更新 2026-07-06 16:34:42 +08:00
long eb0efba739 build 2026-07-03 12:07:34 +08:00
long c7e7022181 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-03 12:04:06 +08:00
long 26e6cc433b Add bulk asset resource deletion 2026-07-03 12:01:49 +08:00
Your Name c22b84cff9 更新 2026-07-03 11:22:07 +08:00
Your Name 4f9486fb3d Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-02 16:35:40 +08:00
Your Name d24b04f9e9 更新 2026-07-02 16:35:24 +08:00
long 465cba3788 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-02 16:30:24 +08:00
long dc590d37e9 修正医生维度接诊率显示 2026-07-02 16:26:24 +08:00
Your Name d1f587fdd7 更新 2026-07-02 16:19:43 +08:00
Your Name e8f68101c4 更新 2026-07-02 16:13:38 +08:00
Your Name cea9392239 Merge branch 'master' into 20260623 2026-07-02 16:08:14 +08:00
Your Name 3ea3c97c42 更新 2026-07-02 14:47:53 +08:00
Your Name efd058bce5 更新 2026-07-02 14:35:20 +08:00
long e538dd03a1 build 2026-07-02 11:57:54 +08:00
long 4a17fe8a0c 调整综合转化统计医生维度口径 2026-07-02 11:55:15 +08:00
Your Name e2a1e62867 更新 2026-06-29 09:28:00 +08:00
Your Name b6cdd20c56 新增 2026-06-29 09:25:29 +08:00
Your Name c1be0aa3e5 更新 2026-06-24 10:07:39 +08:00
Your Name 6c87b72ce4 更新 2026-06-23 11:31:49 +08:00
Your Name e28480af32 更新 2026-06-16 16:16:31 +08:00
Your Name 37b2945c44 更新 2026-06-16 15:47:44 +08:00
Your Name c9c2b4608f 更新 2026-06-16 15:23:59 +08:00
Your Name e61e7f97fa 更新 2026-06-15 10:37:10 +08:00
Your Name 14e5bba9a2 更新 2026-06-15 09:18:59 +08:00
Your Name 6b713a2d6c 更新 2026-06-13 15:00:43 +08:00
Your Name 8ae5b4eead 更新 2026-06-12 18:07:21 +08:00
long e5959fd89a 1 2026-06-11 14:26:41 +08:00
Your Name 08249451a7 i
Merge branch 'master20260610'
2026-06-10 17:30:43 +08:00
Your Name 79ae055bb6 更新 2026-06-10 17:30:05 +08:00
Your Name c0cc23393e 更新 2026-06-10 15:30:38 +08:00
Your Name 470ba419e3 更新 2026-06-10 15:29:07 +08:00
Your Name 1c0cd84355 更新 2026-06-10 15:03:39 +08:00
Your Name f2e2f84e18 i
Merge branch 'master' of https://gitee.com/v_cms/zyt
2026-06-10 14:38:34 +08:00
long f7e52bcafc Merge branch '20260610-long' 2026-06-10 14:37:44 +08:00
long 2e59e883b8 订单详情优化 2026-06-10 14:37:32 +08:00
Your Name 9f1801771e 更新 2026-06-10 14:25:22 +08:00
Your Name 1adf8ccf4b 更新 2026-06-10 10:28:58 +08:00
Your Name abc78f4291 更新 2026-06-10 09:49:46 +08:00
Your Name 072c0c3663 Merge branch 'master' into master-6-9 2026-06-09 11:43:25 +08:00
Your Name e9cf65a41c 更新 2026-06-09 11:42:35 +08:00
long af9bd05bb4 update资源分发小程序 2026-06-09 10:31:18 +08:00
Your Name 18f7fb3772 Merge branch 'master' into master-6-9 2026-06-09 10:17:29 +08:00
Your Name 29ab45033d 更新 2026-06-09 10:16:38 +08:00
long 8a1a9ea0ca Merge branch '20260604-long' 2026-06-09 10:13:49 +08:00
Your Name f8ccd8b0af 更新 2026-06-09 10:11:40 +08:00
long 9e019708f2 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-06-09 10:05:49 +08:00
long 2712c262bd 1 2026-06-09 10:05:44 +08:00
Your Name bd7002e176 更新 2026-06-09 09:42:29 +08:00
Your Name 0be6d06544 更新 2026-06-09 09:31:03 +08:00
long 7a4973470c Merge branch 'codex/latest-appointment-filter' 2026-06-06 15:59:19 +08:00
long e79f3190b8 feat: 支持患者列表按最近挂号筛选
- tcm.diagnosis/lists 新增最近有效挂号时间区间与渠道筛选,lists/count 共用过滤逻辑

- 诊单患者列表更多筛选增加最近挂号时间和渠道,并与顶部挂号快捷筛选互斥

- 明确 daterange-picker change 事件转发,列表补充最近挂号渠道摘要展示
2026-06-06 15:58:23 +08:00
long 1811abc794 完善支付单创建方式链路 2026-06-06 15:25:16 +08:00
long 07abf2abca 支付方式完善 2026-06-05 18:24:41 +08:00
long c76bd5416a 代收金额快照 2026-06-05 17:35:49 +08:00
Your Name 7bb8db4126 更新 2026-06-05 17:22:29 +08:00
Your Name 30b6037752 更新 2026-06-05 17:15:42 +08:00
Your Name 5a235280a1 更新 2026-06-05 17:15:15 +08:00
Your Name b9d36d8d04 更新 2026-06-05 15:51:48 +08:00
Your Name f37310ec50 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-06-05 15:13:10 +08:00
Your Name a440702c07 更新 2026-06-05 15:12:57 +08:00
long b9486f32fe Merge branch '20260604-long' 2026-06-05 12:02:40 +08:00
long 07018598ad add
素材分发 增加用户备注
add_asset_user_remark.sql
2026-06-05 12:02:35 +08:00
Your Name c554e08f2e 更新 2026-06-05 09:38:31 +08:00
long e6209c8912 Merge branch '20260604-long' 2026-06-04 17:37:29 +08:00
Your Name e49f806c3a 更新 2026-06-04 16:51:46 +08:00
Your Name b566e60624 更新 2026-06-04 16:51:25 +08:00
long 123e9075dd 处方增加禁用修改 2026-06-04 15:11:16 +08:00
Your Name 84eef45553 更新 2026-06-04 14:10:22 +08:00
Your Name 9873ac5e87 更新 2026-06-04 11:54:02 +08:00
long cdf8b1f76d udpate 2026-06-04 11:46:32 +08:00
Your Name b0b78c82ab 更新 2026-06-04 11:43:13 +08:00
long ff61e815d8 时间范围筛选优化 2026-06-04 10:51:35 +08:00
Your Name dd93ffe683 更新 2026-06-04 10:22:15 +08:00
Your Name ef014e0d9d 更新 2026-06-04 10:17:47 +08:00
Your Name 32f8e19bf1 更新 2026-06-04 09:57:14 +08:00
Your Name 26b2cde699 更新 2026-06-04 09:34:34 +08:00
Your Name 5ab3be1fdd 新增 2026-06-04 09:25:00 +08:00
long 6b528c34c8 add DoctorNote
诊单 增加医生备注入口
2026-06-03 15:18:12 +08:00
Your Name 2138626519 更新 2026-06-03 14:48:57 +08:00
Your Name 37df208706 更新 2026-06-03 14:42:48 +08:00
Your Name ec4e0b9f29 更新 2026-06-03 11:39:36 +08:00
Your Name a3bae1555a Merge branch 'master-6-2' 2026-06-02 18:35:14 +08:00
Your Name eadc82535d 更新 2026-06-02 16:54:07 +08:00
757 changed files with 22259 additions and 8572 deletions
+1 -4
View File
@@ -26,11 +26,8 @@ bin-release/
/.cursor /.cursor
/.codex /.codex
/.agents /.agents
/.claude
/.claude
/.claude
/.kbctx
/server/.spool /server/.spool
/server/.claude /server/.claude
/.spool /.spool
TUICallKit-Vue3/.env TUICallKit-Vue3/.env
/.codegraph
-120
View File
@@ -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"
-74
View File
@@ -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"
+2
View File
@@ -22,3 +22,5 @@ unpackage/dist
*.sw? *.sw?
TUICallKit TUICallKit
# 本工程的通话源码复用仓库内 ../wx/TUICallKit;保留相对软链接以便干净克隆后可直接构建。
!TUICallKit
+1 -6
View File
@@ -1,7 +1,6 @@
<script setup> <script setup>
// 2. 获取组件实例,通过 proxy 访问全局属性 // 2. 获取组件实例,通过 proxy 访问全局属性
import { ref} from "vue"; import { ref} from "vue";
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager"; import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app' import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
import { getCurrentInstance } from 'vue' import { getCurrentInstance } from 'vue'
@@ -97,11 +96,7 @@ const video =async (data)=>{
}, },
}, false) }, false)
const {userId } = res.data; const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
console.log('SDKAppID',SDKAppID) console.log('SDKAppID',SDKAppID)
getApp().globalData.userID = userId; getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig; getApp().globalData.userSig = userSig;
+1
View File
@@ -0,0 +1 @@
../wx/TUICallKit
+1 -7
View File
@@ -239,7 +239,6 @@
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer'; import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message'; import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
import groupPlugin from '@tencentcloud/lite-chat/plugins/group'; import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index'; import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index'; import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index'; import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
@@ -561,17 +560,12 @@ const { proxy } = getCurrentInstance()
}, },
}, false) }, false)
const {userId } = res.data; const { userId, userSig, sdkAppId: SDKAppID } = res.data;
patient_data.value=res.data.data patient_data.value=res.data.data
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
getApp().globalData.userID = userId; getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig; getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID; getApp().globalData.SDKAppID = SDKAppID;
getApp().globalData.secretKey = secretKey;
uni.setStorageSync('CallManager', userId) uni.setStorageSync('CallManager', userId)
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到) // TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
} }
@@ -1,20 +1,39 @@
<template> <template>
<!-- 页面内嵌模块 weekly 统计页主内容区 --> <!-- 页面内嵌模块 weekly 统计页主内容区 -->
<view v-if="visible && props.inline" class="dev-entry-inline">
<view class="inline-head">
<view class="inline-head-text">
<text class="inline-title">居家耗糖训练</text>
<text class="inline-sub">低强度动起来配合血糖管理</text>
</view>
<view class="inline-wave" aria-hidden="true" />
</view>
<view class="inline-grid">
<view <view
v-for="item in trainingItems" 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" :key="item.url"
class="inline-item" class="inline-card"
@click="goto(item.url)" @click="goto(item.url)"
> >
<view v-if="item.featured" class="inline-card-badge">
<text>推荐</text>
</view>
<view class="menu-icon" :class="item.iconClass"> <view class="menu-icon" :class="item.iconClass">
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape"> <view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" /> <view class="dumbbell-plate dumbbell-plate-left" />
@@ -33,10 +52,14 @@
<view class="metro-bar metro-bar-3" /> <view class="metro-bar metro-bar-3" />
</view> </view>
</view> </view>
<text class="inline-item-title">{{ item.title }}</text> <text class="inline-card-title">{{ item.title }}</text>
<text class="inline-item-sub">{{ item.sub }}</text> <text class="inline-card-sub">{{ item.sub }}</text>
<text class="inline-card-go" aria-hidden="true">进入</text>
</view> </view>
</view> </view>
</scroll-view>
<text v-if="props.tone === 'weekly'" class="inline-scroll-hint">左右滑动选择适合您的训练</text>
</view> </view>
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }"> <view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
@@ -124,15 +147,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
const trainingItems = [ 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/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/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/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/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro' }, { url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
] as const ]
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -140,20 +172,22 @@ const props = withDefaults(
bottom?: number bottom?: number
/** 内嵌到页面主内容区,非 fixed 悬浮 */ /** 内嵌到页面主内容区,非 fixed 悬浮 */
inline?: boolean inline?: boolean
/** weekly 页:与统计页 Vital Mint 视觉一致 */
tone?: 'default' | 'weekly'
}>(), }>(),
{ bottom: 200, inline: false }, { bottom: 200, inline: false, tone: 'default' },
) )
const isDevMode = (): boolean => { /** weekly 将健走节拍器置前并标推荐 */
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */ const inlineItems = computed(() => {
try { if (props.tone !== 'weekly') return trainingItems
return process.env.NODE_ENV !== 'production' const metro = trainingItems.find((i) => i.icon === 'metro')
} catch (_) { const rest = trainingItems.filter((i) => i.icon !== 'metro')
return false return metro ? [metro, ...rest] : trainingItems
} })
}
const visible = ref(isDevMode()) // 居家耗糖训练是正式功能,开发版和发行版都需要展示。
const visible = ref(true)
const expanded = ref(false) const expanded = ref(false)
const toggle = () => { const toggle = () => {
@@ -515,28 +549,59 @@ $brand-light: #34d399;
* ============================================================ */ * ============================================================ */
.dev-entry-inline { .dev-entry-inline {
width: 100%; width: 100%;
padding: 24rpx 24rpx 20rpx;
border-radius: 48rpx;
background: #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);
box-sizing: border-box; box-sizing: border-box;
} }
.inline-head { .dev-entry-inline--weekly {
display: flex; padding: 32rpx 28rpx 24rpx;
align-items: center; border-radius: 48rpx;
justify-content: space-between; background: var(--surface-container-lowest, #fff);
gap: 16rpx; border: 1rpx solid rgba(0, 108, 73, 0.12);
margin-bottom: 20rpx; box-shadow:
0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08),
0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
} }
.inline-head-text { .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; flex: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4rpx; gap: 6rpx;
}
.inline-hero-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
} }
.inline-title { .inline-title {
@@ -546,75 +611,119 @@ $brand-light: #34d399;
line-height: 1.25; 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 { .inline-sub {
font-size: 24rpx; font-size: 26rpx;
font-weight: 500; font-weight: 500;
color: #64748b; color: #475569;
line-height: 1.35; line-height: 1.4;
} }
.inline-wave { .inline-scroll {
width: 120rpx; width: 100%;
height: 48rpx; white-space: nowrap;
flex-shrink: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 40' fill='none'><path d='M0 20 H18 L26 8 L34 32 L42 14 L50 26 L58 20 H120' stroke='%23006c49' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' opacity='0.85'/></svg>");
background-size: contain;
background-repeat: no-repeat;
background-position: center right;
} }
/* 五行并列,一屏展示完整(不横向滑动) */ .inline-scroll-track {
.inline-grid { display: inline-flex;
display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
justify-content: space-between; gap: 16rpx;
gap: 8rpx; padding: 4rpx 2rpx 8rpx;
width: 100%;
} }
.inline-item { .inline-card {
flex: 1; position: relative;
min-width: 0; display: inline-flex;
display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: flex-start;
padding: 14rpx 4rpx 12rpx; width: 212rpx;
border-radius: 20rpx; min-height: 220rpx;
background: #f0fdf4; padding: 20rpx 18rpx 18rpx;
border-radius: 28rpx;
background: #f4f7f5;
border: 1rpx solid rgba(0, 108, 73, 0.1); border: 1rpx solid rgba(0, 108, 73, 0.1);
box-sizing: border-box; box-sizing: border-box;
flex-shrink: 0;
transition: transform 0.2s ease, background 0.2s ease;
&:active { &:active {
transform: scale(0.97); transform: scale(0.98);
opacity: 0.9; background: #e8f3ee;
} }
.menu-icon { .menu-icon {
width: 56rpx; width: 72rpx;
height: 56rpx; height: 72rpx;
margin-bottom: 8rpx; margin-bottom: 14rpx;
flex-shrink: 0; flex-shrink: 0;
} }
} }
.inline-item-title { .inline-card-badge {
font-size: 22rpx; position: absolute;
top: 12rpx;
right: 12rpx;
padding: 4rpx 10rpx;
border-radius: 8rpx;
background: #006c49;
text {
font-size: 18rpx;
font-weight: 700; font-weight: 700;
color: #0f172a; color: #fff;
text-align: center;
line-height: 1.2; line-height: 1.2;
width: 100%; }
word-break: break-all;
} }
.inline-item-sub { .inline-card-title {
margin-top: 2rpx; font-size: 28rpx;
font-size: 18rpx; 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; color: #94a3b8;
text-align: center; text-align: center;
line-height: 1.2; line-height: 1.35;
width: 100%; }
word-break: break-all;
@media (prefers-reduced-motion: reduce) {
.inline-card:active {
transform: none;
}
} }
</style> </style>
@@ -178,7 +178,6 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { getCurrentInstance } from 'vue' import { getCurrentInstance } from 'vue'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager"; import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
let userId = ref(""); let userId = ref("");
@@ -379,10 +378,11 @@ const loginHandler = async (patient_id) => {
data: { patient_id: patient_id }, data: { patient_id: patient_id },
}, false) }, false)
const { userId: patientUserId } = res.data const {
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({ userId: patientUserId,
userID: patientUserId, userSig: patientUserSig,
}) sdkAppId: patientSDKAppID,
} = res.data
console.log('获取签名成功:', patientUserSig) console.log('获取签名成功:', patientUserSig)
// 更新ref值 // 更新ref值
+5 -4
View File
@@ -51,18 +51,19 @@
/* */ /* */
"mp-weixin" : { "mp-weixin" : {
"appid" : "wx79b9a0bfbfe7cbcd", "appid" : "wx79b9a0bfbfe7cbcd",
"lazyCodeLoading" : "requiredComponents",
"setting" : { "setting" : {
"urlCheck" : false "urlCheck" : false,
"minified" : true
}, },
"optimization" : { "optimization" : {
"subPackages" : true "subPackages" : true
}, },
"usingComponents" : true, "usingComponents" : true,
"requiredBackgroundModes" : ["audio"], "requiredBackgroundModes" : [ "audio" ],
"plugins" : { "plugins" : {
"WechatSI" : { "WechatSI" : {
"version" : "0.3.5", "version" : "0.3.9",
"provider" : "wx069ba97219f66d99" "provider" : "wx069ba97219f66d99"
} }
} }
+30 -15
View File
@@ -26,14 +26,6 @@
"navigationBarTitleText": "甄养堂" "navigationBarTitleText": "甄养堂"
} }
}, },
{
"path": "TUICallKit/src/Components/TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
},
{ {
"path": "pages/order/monad/monad", "path": "pages/order/monad/monad",
"style": { "style": {
@@ -76,6 +68,19 @@
} }
], ],
"subPackages": [ "subPackages": [
{
"root": "TUICallKit/src/Components",
"pages": [
{
"path": "TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
}
]
},
{ {
"root": "TUIKit", "root": "TUIKit",
"pages": [ "pages": [
@@ -174,7 +179,7 @@
"mp-weixin": { "mp-weixin": {
"usingPlugins": { "usingPlugins": {
"WechatSI": { "WechatSI": {
"version": "0.3.5", "version": "0.3.9",
"provider": "wx069ba97219f66d99" "provider": "wx069ba97219f66d99"
} }
} }
@@ -191,7 +196,7 @@
"mp-weixin": { "mp-weixin": {
"usingPlugins": { "usingPlugins": {
"WechatSI": { "WechatSI": {
"version": "0.3.5", "version": "0.3.9",
"provider": "wx069ba97219f66d99" "provider": "wx069ba97219f66d99"
} }
} }
@@ -204,16 +209,26 @@
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "日常护理", "navigationBarTitleText": "日常护理",
"backgroundColor": "#f4fbf4", "backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true "enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.9",
"provider": "wx069ba97219f66d99"
}
}
}
} }
}, },
{ {
"path": "pages/game", "path": "endless-game/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "糖分突袭", "navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#c7d2fe", "backgroundColor": "#eefbf4",
"disableScroll": true "disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
} }
} }
] ]
+2 -7
View File
@@ -62,7 +62,6 @@
import { ref,onMounted,getCurrentInstance} from "vue"; import { ref,onMounted,getCurrentInstance} from "vue";
// 2. 获取组件实例,通过 proxy 访问全局属性 // 2. 获取组件实例,通过 proxy 访问全局属性
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager"; import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app' import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
let userID = ref("4"); let userID = ref("4");
@@ -140,13 +139,9 @@ const loginHandler = async (patient_id) => {
const {userId } = res.data; const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
console.log('获取签名成功:',userSig); console.log('获取签名成功:',userSig);
getApp().globalData.userID = userId.value; getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig; getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID; getApp().globalData.SDKAppID = SDKAppID;
+1 -5
View File
@@ -52,7 +52,6 @@
<script setup> <script setup>
import { ref, getCurrentInstance,onMounted } from "vue"; import { ref, getCurrentInstance,onMounted } from "vue";
import { onShow, onShareAppMessage } from '@dcloudio/uni-app' import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager"; import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
let userId = ref(""); let userId = ref("");
@@ -126,10 +125,7 @@ const loginHandler = async (patient_id) => {
data: { patient_id: patient_id }, data: { patient_id: patient_id },
}, false) }, false)
const {userId } = res.data; const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
console.log('获取签名成功:',userSig); console.log('获取签名成功:',userSig);
getApp().globalData.userID = userId; getApp().globalData.userID = userId;
+12 -17
View File
@@ -370,7 +370,6 @@
import { ref,onMounted,getCurrentInstance } from "vue"; import { ref,onMounted,getCurrentInstance } from "vue";
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app' import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
// 2. 获取组件实例,通过 proxy 访问全局属性 // 2. 获取组件实例,通过 proxy 访问全局属性
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager"; import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
@@ -667,25 +666,25 @@ const closePatientModal = async () => {
const granted = await requestAVPermissionsInModal(); const granted = await requestAVPermissionsInModal();
if (!granted) return; // 未授权,停止跳转 if (!granted) return; // 未授权,停止跳转
const app = getApp(); const app = getApp();
// const userID = app.globalData.userID;
// const userSig = app.globalData.userSig;
// const SDKAppID = app.globalData.SDKAppID;
const doctor = 'doctor_' + doctorId.value; const doctor = 'doctor_' + doctorId.value;
await confirmDiagnosis(diagnosisId); await confirmDiagnosis(diagnosisId);
if(!diagnosisId.includes('patient')){ const signatureRes = await proxy.apiUrl({
diagnosisId='patient_'+diagnosisId url: '/api/tcm/getPatientSignature',
} method: 'GET',
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({ data: { patient_id: diagnosisId },
userID:diagnosisId, }, false);
}); const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
if (!doctorId.value) { if (!doctorId.value) {
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' }); uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
return; return;
} }
console.log(app.globalData, diagnosisId); app.globalData.userID = userId;
app.globalData.userSig = userSig;
app.globalData.SDKAppID = SDKAppID;
console.log(app.globalData, userId);
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(diagnosisId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1` const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
showPatientModal.value = false; showPatientModal.value = false;
uni.redirectTo({ uni.redirectTo({
@@ -707,11 +706,7 @@ const video =async (data)=>{
}, },
}, false) }, false)
const {userId } = res.data; const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
getApp().globalData.userID = userId; getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig; getApp().globalData.userSig = userSig;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 45 KiB

@@ -1,116 +0,0 @@
<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="size === 'tile' ? 'aspectFill' : '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: 58%;
height: 58%;
z-index: 2;
}
.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,189 @@
<template>
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
<view class="vsm-body">
<view class="vsm-leaf" aria-hidden="true" />
<view class="vsm-face">
<view class="vsm-eye vsm-eye--l" />
<view class="vsm-eye vsm-eye--r" />
<view class="vsm-blush vsm-blush--l" />
<view class="vsm-blush vsm-blush--r" />
<view class="vsm-mouth" />
</view>
<view v-if="active" class="vsm-waves" aria-hidden="true">
<view class="vsm-wave vsm-wave--1" />
<view class="vsm-wave vsm-wave--2" />
<view class="vsm-wave vsm-wave--3" />
</view>
</view>
</view>
</template>
<script setup>
defineProps({
/** 正在按住说话 / 录音中 */
active: { type: Boolean, default: false },
/** 深色条背景上使用浅色卡通 */
onDark: { type: Boolean, default: false },
size: { type: String, default: 'md' }
})
</script>
<style scoped lang="scss">
.vsm {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vsm--md { width: 72rpx; height: 72rpx; }
.vsm--sm { width: 64rpx; height: 64rpx; }
.vsm-body {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.vsm-leaf {
position: absolute;
top: 2rpx;
left: 50%;
width: 16rpx;
height: 16rpx;
margin-left: -8rpx;
border-radius: 0 100% 0 100%;
background: #34d399;
transform: rotate(-18deg);
z-index: 2;
}
.vsm--on-dark .vsm-leaf {
background: #a7f3d0;
}
.vsm-face {
position: relative;
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #fef9c3;
border: 3rpx solid #047857;
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
z-index: 1;
}
.vsm--sm .vsm-face {
width: 46rpx;
height: 46rpx;
}
.vsm--on-dark .vsm-face {
background: #fffbeb;
border-color: rgba(255, 255, 255, 0.85);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
}
.vsm-eye {
position: absolute;
top: 16rpx;
width: 6rpx;
height: 8rpx;
border-radius: 50%;
background: #064e3b;
}
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
.vsm--on-dark .vsm-eye { background: #134e4a; }
.vsm-eye--l { left: 12rpx; }
.vsm-eye--r { right: 12rpx; }
.vsm--sm .vsm-eye--l { left: 10rpx; }
.vsm--sm .vsm-eye--r { right: 10rpx; }
.vsm-blush {
position: absolute;
top: 24rpx;
width: 10rpx;
height: 6rpx;
border-radius: 50%;
background: rgba(251, 113, 133, 0.45);
opacity: 0.85;
}
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
.vsm-blush--l { left: 6rpx; }
.vsm-blush--r { right: 6rpx; }
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
.vsm-mouth {
position: absolute;
left: 50%;
bottom: 10rpx;
width: 18rpx;
height: 8rpx;
margin-left: -9rpx;
border-radius: 0 0 18rpx 18rpx;
background: #047857;
transform-origin: center top;
}
.vsm--sm .vsm-mouth {
bottom: 9rpx;
width: 16rpx;
height: 7rpx;
margin-left: -8rpx;
}
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
.vsm-waves {
position: absolute;
right: -2rpx;
top: 50%;
display: flex;
align-items: flex-end;
gap: 4rpx;
height: 28rpx;
margin-top: -14rpx;
z-index: 0;
}
.vsm-wave {
width: 5rpx;
border-radius: 4rpx;
background: #047857;
animation: vsm-wave-jump 0.72s ease-in-out infinite;
}
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
.vsm--active .vsm-body {
animation: vsm-bob 0.9s ease-in-out infinite;
}
.vsm--active .vsm-mouth {
animation: vsm-talk 0.32s ease-in-out infinite alternate;
}
.vsm--active .vsm-eye {
animation: vsm-blink 2.4s ease-in-out infinite;
}
@keyframes vsm-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4rpx); }
}
@keyframes vsm-talk {
0% {
transform: scaleY(0.45);
border-radius: 0 0 18rpx 18rpx;
}
100% {
transform: scaleY(1.15);
height: 12rpx;
border-radius: 50%;
}
}
@keyframes vsm-blink {
0%, 42%, 46%, 100% { transform: scaleY(1); }
44% { transform: scaleY(0.15); }
}
@keyframes vsm-wave-jump {
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
50% { transform: scaleY(1); opacity: 1; }
}
</style>
@@ -0,0 +1,203 @@
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
const emptyBloodCarePlan = () => ({
summary: '',
blood_advice: '',
tcm_plan: '',
watch_points: [],
next_steps: '',
control_level: '',
control_label: '',
disclaimer: '',
analysis: [],
source: '',
date: ''
})
/** AI 血糖照护建议(累计 7 天有记录后) */
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
const bloodCarePlan = ref(emptyBloodCarePlan())
const bloodCareLoading = ref(false)
const bloodCareStreaming = ref(false)
const bloodCareStreamField = ref('')
let careStreamBuffer = ''
/** 用户点击后才展开照护建议卡片 */
const bloodCarePanelOpen = ref(false)
const bloodCareEligible = computed(() => {
return !!diagnosisId.value && !chartUseMockData.value
})
const hasBloodCareContent = computed(() => {
const p = bloodCarePlan.value
return !!(p.summary || p.blood_advice || p.tcm_plan)
})
const bloodCareAnalysis = computed(() => {
const list = bloodCarePlan.value?.analysis
return Array.isArray(list) ? list : []
})
const bloodCareControlLabel = computed(() => {
const label = String(bloodCarePlan.value?.control_label || '').trim()
return label
})
const bloodCareDisclaimer = computed(() => {
return String(bloodCarePlan.value?.disclaimer || '').trim()
})
function detectCareStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[:]\s*([^\n]*)$/)
if (!tail) return 'summary'
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
return map[tail[1]] || 'summary'
}
function mergePartialCareFromStream() {
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
const next = { ...bloodCarePlan.value }
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
bloodCarePlan.value = next
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
}
function applyBloodCarePayload(payload) {
if (!payload) return
const prevAnalysis = bloodCarePlan.value.analysis
bloodCarePlan.value = {
...emptyBloodCarePlan(),
...payload,
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
}
}
function onBloodCareStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
}
} else if (event === 'delta') {
bloodCareStreaming.value = true
if (payload.buffer != null) {
careStreamBuffer = payload.buffer
} else {
careStreamBuffer += payload.text || ''
}
mergePartialCareFromStream()
} else if (event === 'done') {
applyBloodCarePayload(payload)
careStreamBuffer = ''
bloodCareStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
}
}
async function fetchBloodCareAiFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyBloodCareAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyBloodCarePayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
function closeBloodCarePanel() {
bloodCarePanelOpen.value = false
}
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
async function toggleBloodCareFromChart() {
if (!bloodCareEligible.value || bloodCareLoading.value) return
if (bloodCarePanelOpen.value) {
closeBloodCarePanel()
return
}
bloodCarePanelOpen.value = true
if (!hasBloodCareContent.value) {
await fetchBloodCareAi(false)
}
}
async function fetchBloodCareAi(refresh = false) {
if (!bloodCareEligible.value || bloodCareLoading.value) return
bloodCareLoading.value = true
bloodCareStreaming.value = false
careStreamBuffer = ''
bloodCareStreamField.value = ''
if (refresh) {
applyBloodCarePayload(emptyBloodCarePlan())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyBloodCareAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onBloodCareStreamEvent,
fallback: () => fetchBloodCareAiFallback(refresh)
})
} catch (e) {
try {
await fetchBloodCareAiFallback(refresh)
} catch (e2) {
showUserToast('照护建议加载失败')
}
} finally {
bloodCareLoading.value = false
bloodCareStreaming.value = false
bloodCareStreamField.value = ''
}
}
function refreshBloodCareAi() {
if (bloodCareLoading.value) return
fetchBloodCareAi(true)
}
function resetBloodCarePlan() {
applyBloodCarePayload(emptyBloodCarePlan())
careStreamBuffer = ''
bloodCareStreamField.value = ''
bloodCarePanelOpen.value = false
}
return {
bloodCarePlan,
bloodCareLoading,
bloodCareStreaming,
bloodCareStreamField,
bloodCareEligible,
bloodCarePanelOpen,
hasBloodCareContent,
bloodCareAnalysis,
bloodCareControlLabel,
bloodCareDisclaimer,
fetchBloodCareAi,
toggleBloodCareFromChart,
closeBloodCarePanel,
refreshBloodCareAi,
resetBloodCarePlan
}
}
@@ -5,6 +5,7 @@ const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
const emptyDietAiRecommend = () => ({ const emptyDietAiRecommend = () => ({
breakfast: '', breakfast: '',
drinks: '',
lunch: '', lunch: '',
dinner: '', dinner: '',
tips: '', tips: '',
@@ -31,20 +32,20 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
const hasDietAiMealContent = computed(() => { const hasDietAiMealContent = computed(() => {
const r = dietAiRecommend.value const r = dietAiRecommend.value
return !!(r.breakfast || r.lunch || r.dinner || r.tips) return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
}) })
function detectDietStreamField(text) { function detectDietStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/) const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (!tail) return 'breakfast' if (!tail) return 'breakfast'
const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' } const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
return map[tail[1]] || 'breakfast' return map[tail[1]] || 'breakfast'
} }
function mergePartialDietFromStream() { function mergePartialDietFromStream() {
const partial = parsePartialDietPlainText(dietStreamBuffer) const partial = parsePartialDietPlainText(dietStreamBuffer)
const next = { ...dietAiRecommend.value } const next = { ...dietAiRecommend.value }
for (const key of ['breakfast', 'lunch', 'dinner', 'tips']) { for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
if (partial[key] != null) next[key] = partial[key] if (partial[key] != null) next[key] = partial[key]
} }
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
@@ -80,6 +81,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
...emptyDietAiRecommend(), ...emptyDietAiRecommend(),
...payload, ...payload,
breakfast: streamed.breakfast, breakfast: streamed.breakfast,
drinks: streamed.drinks || payload.drinks || '',
lunch: streamed.lunch || payload.lunch || '', lunch: streamed.lunch || payload.lunch || '',
dinner: streamed.dinner || payload.dinner || '', dinner: streamed.dinner || payload.dinner || '',
tips: streamed.tips || payload.tips || '', tips: streamed.tips || payload.tips || '',
@@ -205,6 +207,8 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
return return
} }
if (!diagnosisId.value) return if (!diagnosisId.value) return
// 发送后立即清空输入框
dietAiAskText.value = ''
dietAiAsking.value = true dietAiAsking.value = true
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' } dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
try { try {
@@ -259,6 +263,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
try { try {
uni.setStorageSync(DIET_AI_PREFILL_KEY, { uni.setStorageSync(DIET_AI_PREFILL_KEY, {
breakfast: r.breakfast, breakfast: r.breakfast,
drinks: r.drinks || '',
lunch: r.lunch, lunch: r.lunch,
dinner: r.dinner, dinner: r.dinner,
tips: r.tips || '' tips: r.tips || ''
@@ -1,188 +0,0 @@
/**
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
*/
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
}
}
@@ -1,112 +0,0 @@
/**
* 游戏进度持久化:关卡、星级、连胜、每日打卡
*/
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
}
}
@@ -2,22 +2,106 @@ import { ref, onUnmounted } from 'vue'
const MIN_HOLD_MS = 280 const MIN_HOLD_MS = 280
/** 短按/过早松手时插件常见错误,不应打扰用户 */
function isBenignSttError(msg, retcode) {
const m = String(msg || '').toLowerCase()
if (/internal voice data failed/i.test(m)) return true
if (/voice data failed/i.test(m)) return true
if (/recordfailed|record failed|record manager/i.test(m)) return true
if (/please stop after start/i.test(m)) return true
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
const code = Number(retcode)
if (code === -30002 || code === -30003 || code === -30012) return true
return false
}
function friendlySttError(msg) {
const m = String(msg || '').trim()
if (!m || isBenignSttError(m)) return ''
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
return '语音识别失败,请重试'
}
// #ifdef MP-WEIXIN
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
// 回调仍指向已销毁页面的闭包,新页面收不到 onStart/onStop
// 其本地状态卡在 recording,表现为一直录制/无法再次语音。
let sharedManager = null
let sharedManagerInited = false
let sharedRecording = false
let activeCtl = null
function getSharedRecordManager() {
if (sharedManagerInited) return sharedManager
sharedManagerInited = true
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
sharedManager = plugin.getRecordRecognitionManager()
} catch (e) {
console.warn('WechatSI record recognition not available', e)
sharedManager = null
return null
}
if (!sharedManager) return null
sharedManager.onStart = () => {
sharedRecording = true
if (activeCtl) activeCtl.handleStart()
}
sharedManager.onRecognize = (res) => {
if (activeCtl) activeCtl.handleRecognize(res)
}
sharedManager.onStop = (res) => {
sharedRecording = false
if (activeCtl) activeCtl.handleStop(res)
}
sharedManager.onError = (res) => {
sharedRecording = false
if (activeCtl) activeCtl.handleError(res)
}
return sharedManager
}
// #endif
/** /**
* 语音转文字(长按说话):微信小程序 WechatSIH5 Web Speech API。 * 语音转文字(长按说话):微信小程序 WechatSIH5 Web Speech API。
* *
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options * @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, showToast } = {}) { export function useSpeechToText({ onResult, onSettle, onPartial, onError, showToast } = {}) {
const sttListening = ref(false) const sttListening = ref(false)
const sttHolding = ref(false) const sttHolding = ref(false)
const sttSupported = ref(false) const sttSupported = ref(false)
let wxRecordManager = null let wxRecordManager = null
let h5Recognition = null let h5Recognition = null
let h5GotResult = false
let holdStartTs = 0 let holdStartTs = 0
let holdSessionId = 0 let holdSessionId = 0
let startRequested = false let startRequested = false
let recordAuthorized = null let recordAuthorized = null
// 快速点按可能残留会话:用排队标记把 start/stop 串行化,
// 避免 "please stop after start"
let pendingStartSession = 0
function notify(msg) { function notify(msg) {
if (!msg) return if (!msg) return
@@ -31,6 +115,11 @@ export function useSpeechToText({ onResult, showToast } = {}) {
function emitResult(text) { function emitResult(text) {
const t = String(text || '').trim() const t = String(text || '').trim()
const heldMs = Date.now() - holdStartTs const heldMs = Date.now() - holdStartTs
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
if (typeof onSettle === 'function') {
const handled = onSettle(t, { heldMs })
if (handled === true) return
}
if (!t) { if (!t) {
if (heldMs < MIN_HOLD_MS) return if (heldMs < MIN_HOLD_MS) return
notify('没听清,请再试一次') notify('没听清,请再试一次')
@@ -43,7 +132,9 @@ export function useSpeechToText({ onResult, showToast } = {}) {
function stopListening() { function stopListening() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
if (wxRecordManager && (sttListening.value || startRequested)) { const ownsRecording = sharedRecording && activeCtl === controller
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
if (wxRecordManager && (sttListening.value || ownsRecording)) {
try { try {
wxRecordManager.stop() wxRecordManager.stop()
} catch (e) {} } catch (e) {}
@@ -59,6 +150,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
// #endif // #endif
startRequested = false startRequested = false
sttHolding.value = false
sttListening.value = false sttListening.value = false
} }
@@ -95,8 +187,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
// #endif // #endif
} }
function startWechatRecord(session) {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
function actuallyStartWechat(session) {
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) { if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
return return
} }
@@ -108,9 +200,28 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}) })
} catch (e) { } catch (e) {
startRequested = false startRequested = false
sharedRecording = false
sttHolding.value = false sttHolding.value = false
notify('无法开始录音') 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 // #endif
} }
@@ -123,6 +234,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
} }
try { try {
startRequested = true startRequested = true
h5GotResult = false
h5Recognition.start() h5Recognition.start()
sttListening.value = true sttListening.value = true
} catch (e) { } catch (e) {
@@ -146,6 +258,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
sttHolding.value = true sttHolding.value = true
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
// 把全局录音回调切到当前实例
activeCtl = controller
ensureRecordAuth(() => startWechatRecord(session)) ensureRecordAuth(() => startWechatRecord(session))
// #endif // #endif
// #ifdef H5 // #ifdef H5
@@ -159,50 +273,95 @@ export function useSpeechToText({ onResult, showToast } = {}) {
// #endif // #endif
} }
/** 松手结束:手指抬起 */ /** 松手结束:手指抬起force=true 时强制取消尚未真正开始的按住会话 */
function endHoldSpeech() { function endHoldSpeech(force = false) {
if (!sttHolding.value && !sttListening.value && !startRequested) { let recording = false
// #ifdef MP-WEIXIN
recording = sharedRecording && activeCtl === controller
// #endif
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
return return
} }
sttHolding.value = false sttHolding.value = false
if (sttListening.value || startRequested) { // 松手后不再补开排队的录音
pendingStartSession = 0
if (sttListening.value || recording) {
stopListening() stopListening()
return return
} }
if (startRequested) {
// start 已发出但 onStart 未到:取消会话,勿调 stop()
startRequested = false
holdSessionId += 1
return
}
holdSessionId += 1 holdSessionId += 1
} }
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
try { // 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
// eslint-disable-next-line no-undef // 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
const plugin = requirePlugin('WechatSI') const controller = {
wxRecordManager = plugin.getRecordRecognitionManager() handleStart() {
sttSupported.value = !!wxRecordManager
wxRecordManager.onStart = () => {
if (!sttHolding.value) { if (!sttHolding.value) {
try { // 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
wxRecordManager.stop() startRequested = false
} catch (e) {} sttListening.value = false
return return
} }
startRequested = false startRequested = false
sttListening.value = true sttListening.value = true
} },
wxRecordManager.onStop = (res) => { handleRecognize(res) {
if (typeof onPartial !== 'function') return
const t = String(res?.result || '').trim()
if (t) onPartial(t)
},
handleStop(res) {
sttListening.value = false sttListening.value = false
startRequested = false startRequested = false
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
if (pendingStartSession && pendingStartSession === holdSessionId && sttHolding.value) {
const s = pendingStartSession
pendingStartSession = 0
actuallyStartWechat(s)
return
}
pendingStartSession = 0
emitResult(res?.result) emitResult(res?.result)
} },
wxRecordManager.onError = (res) => { handleError(res) {
sttListening.value = false sttListening.value = false
startRequested = false startRequested = false
const msg = (res && res.msg) || ''
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
if (/please stop after start/i.test(msg)) {
try {
wxRecordManager.stop()
} catch (e) {}
pendingStartSession = sttHolding.value ? holdSessionId : 0
return
}
pendingStartSession = 0
sttHolding.value = false sttHolding.value = false
notify(res?.msg || '语音识别失败') const heldMs = Date.now() - holdStartTs
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
if (typeof onError === 'function') onError(msg || '')
return
} }
} catch (e) { // 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
console.warn('WechatSI record recognition not available', e) if (typeof onError === 'function') {
const handled = onError(msg || '')
if (handled === true) return
} }
const tip = friendlySttError(msg)
if (tip) notify(tip)
}
}
wxRecordManager = getSharedRecordManager()
sttSupported.value = !!wxRecordManager
// #endif // #endif
// #ifdef H5 // #ifdef H5
@@ -212,27 +371,49 @@ export function useSpeechToText({ onResult, showToast } = {}) {
if (SR) { if (SR) {
h5Recognition = new SR() h5Recognition = new SR()
h5Recognition.lang = 'zh-CN' h5Recognition.lang = 'zh-CN'
h5Recognition.interimResults = false h5Recognition.interimResults = true
h5Recognition.continuous = false h5Recognition.continuous = false
h5Recognition.maxAlternatives = 1 h5Recognition.maxAlternatives = 1
sttSupported.value = true sttSupported.value = true
h5Recognition.onresult = (event) => { h5Recognition.onresult = (event) => {
const item = event.results?.[0]?.[0] let interim = ''
emitResult(item?.transcript || '') 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 = () => { h5Recognition.onend = () => {
sttListening.value = false sttListening.value = false
startRequested = false startRequested = false
sttHolding.value = false sttHolding.value = false
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
if (!h5GotResult) {
emitResult('')
}
h5GotResult = false
} }
h5Recognition.onerror = (event) => { h5Recognition.onerror = (event) => {
sttListening.value = false sttListening.value = false
startRequested = false startRequested = false
sttHolding.value = false sttHolding.value = false
if (event?.error === 'not-allowed') { 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('请允许浏览器使用麦克风') notify('请允许浏览器使用麦克风')
} else if (event?.error !== 'aborted') { } else {
notify('语音识别失败') notify('语音识别失败')
} }
} }
@@ -246,7 +427,14 @@ export function useSpeechToText({ onResult, showToast } = {}) {
onUnmounted(() => { onUnmounted(() => {
sttHolding.value = false sttHolding.value = false
holdSessionId += 1 holdSessionId += 1
pendingStartSession = 0
stopListening() stopListening()
// #ifdef MP-WEIXIN
// 释放全局回调指向,避免事件继续派发给已销毁的实例
if (activeCtl === controller) {
activeCtl = null
}
// #endif
}) })
return { return {
@@ -258,3 +446,5 @@ export function useSpeechToText({ onResult, showToast } = {}) {
stopSpeechToText: endHoldSpeech stopSpeechToText: endHoldSpeech
} }
} }
export { isBenignSttError, friendlySttError }
-654
View File
@@ -1,654 +0,0 @@
/**
* 棋盘食材 · 仿真插画 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))
}
@@ -1,49 +0,0 @@
/**
* 食材图标 URLTwemoji 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
const url = 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
})
}
@@ -1,175 +0,0 @@
/**
* 全量食材库: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: '蛋白为主,升糖低'
}
/** 含糖等级通用科普文案 */
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] || ''
}
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' },
// 高 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' }
]
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() {
return 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: getFoodImgUrl(food.icon)
}
}
-33
View File
@@ -1,33 +0,0 @@
/**
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
*/
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,
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,41 @@
# 识糖小课堂平台接入说明
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期自动分配最多 7 人的混合同行组,前期不区分男女。
- 本周已经进入旧男女组的账号,下次读取榜单时保留成绩并自动迁入混合组。
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
## 接口
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key``learned_count``score``ended`
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`
## 部署顺序
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php``TcmController.php`
3. 重新构建并上传小程序前端。
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
## 上线前检查
- 用不同账号进入,确认按进入顺序加入同一个最多 7 人的混合同行组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
- 周一验证新周重新分组,旧周成绩不带入新周。
@@ -0,0 +1,43 @@
# 识糖小课堂独立功能包
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
## 目录内容
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
- `game-endless.scss`:完整页面与动画样式。
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
## 迁移步骤
1. 复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录,并确保目标项目同时提供 `tongji/components/TongjiIcon.vue``tongji/utils/svgDataUrl.js`
2. 在目标项目 `pages.json``tongji` 分包 `pages` 数组中加入:
```json
{
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
```
3. 使用 `/tongji/endless-game/index` 打开游戏。
4. 微信小程序后台需要将 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com` 配置为合法的音频与图片下载域名。
## 说明
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`
- 本功能包依赖 uni-app Vue 3、`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
- 食物风险等级和科普文案集中在 `index.vue``FOODS` 配置中,正式上线前仍需由医院医生审核。
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

@@ -0,0 +1,577 @@
#!/usr/bin/env node
// 识糖小课堂棋盘压力测试:复刻生产版的 5x4 棋盘、掉落、洗牌和渐进换组规则。
// 运行:node tongji/endless-game/build/stress-game.mjs --runs 10000
const ROWS = 5
const COLS = 4
const HIGH_CAP = 3
const MAX_CASCADE_WAVES = 3
const DANGER_DROP_CHANCE = 0.1
const COLUMN_GROUP_BIAS = 0.72
const FOOD_GROUPS = [
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
{ title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' },
{ title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' },
{ title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
{ title: '下午茶甜饮', safe: ['milk', 'tea', 'apple', 'strawberry'], danger: 'milkTea' },
{ title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
{ title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
{ title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' }
]
const HIGH_KEYS = new Set(FOOD_GROUPS.map(group => group.danger))
const SAFE_KEYS = new Set(FOOD_GROUPS.flatMap(group => group.safe))
function readNumberArg(name, fallback) {
const index = process.argv.indexOf(name)
if (index < 0) return fallback
const value = Number(process.argv[index + 1])
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
}
const RUNS = readNumberArg('--runs', 10000)
const TURNS = readNumberArg('--turns', 80)
const BASE_SEED = readNumberArg('--seed', 20260722)
function createRng(seed) {
let state = seed >>> 0 || 1
return () => {
state ^= state << 13
state ^= state >>> 17
state ^= state << 5
return (state >>> 0) / 4294967296
}
}
function cloneBoard(source) {
return source.map(tile => tile ? { ...tile } : null)
}
function tile(key) {
return { key }
}
function isAdjacent(a, b) {
const ar = Math.floor(a / COLS)
const ac = a % COLS
const br = Math.floor(b / COLS)
const bc = b % COLS
return Math.abs(ar - br) + Math.abs(ac - bc) === 1
}
function swap(source, a, b) {
const next = cloneBoard(source)
;[next[a], next[b]] = [next[b], next[a]]
return next
}
function shuffle(source, rng) {
const result = cloneBoard(source)
for (let i = result.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1))
;[result[i], result[j]] = [result[j], result[i]]
}
return result
}
function findMatches(source) {
const groups = []
for (let row = 0; row < ROWS; row += 1) {
let start = 0
while (start < COLS) {
let end = start + 1
while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1
if (source[row * COLS + start] && end - start >= 3) {
groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) })
}
start = end
}
}
for (let col = 0; col < COLS; col += 1) {
let start = 0
while (start < ROWS) {
let end = start + 1
while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1
if (source[start * COLS + col] && end - start >= 3) {
groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) })
}
start = end
}
}
return groups
}
function enumerateMatchingMoves(source) {
const moves = []
for (let i = 0; i < source.length; i += 1) {
const candidates = []
if (i % COLS < COLS - 1) candidates.push(i + 1)
if (i + COLS < source.length) candidates.push(i + COLS)
for (const j of candidates) {
const copy = swap(source, i, j)
const groups = findMatches(copy)
if (!groups.length) continue
const indices = new Set(groups.flatMap(group => group.indices))
const hitsHigh = [...indices].some(index => HIGH_KEYS.has(copy[index]?.key))
moves.push({ from: i, to: j, hitsHigh, groups })
}
}
return moves
}
function safeMoves(source) {
return enumerateMatchingMoves(source).filter(move => !move.hitsHigh)
}
function wouldCreateMatchAt(source, index, key) {
const row = Math.floor(index / COLS)
const col = index % COLS
const keyAt = target => target === index ? key : source[target]?.key
let horizontal = 1
for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1
for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1
if (horizontal >= 3) return true
let vertical = 1
for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1
for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1
return vertical >= 3
}
function openingKeys(state) {
const [a, b, c, d] = state.activeSafeKeys
const danger = FOOD_GROUPS[state.themeIndex].danger
return [
a, b, c, d,
b, c, d, a,
c, d, a, danger,
d, a, danger, b,
a, b, c, d
]
}
function forcedPlayableKeys(state) {
const [a, b, c, d] = state.activeSafeKeys
const danger = FOOD_GROUPS[state.themeIndex].danger
return [
d, a, danger, c,
d, c, b, danger,
c, a, b, a,
d, d, a, a,
b, d, c, b
]
}
function buildPlayableBoard(state, keys = openingKeys(state)) {
let candidate = keys.map(tile)
for (let attempt = 0; attempt < 240; attempt += 1) {
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
candidate = shuffle(candidate, state.rng)
}
state.metrics.guaranteedFallbacks += 1
return forcedPlayableKeys(state).map(tile)
}
function makeStablePlayableBoard(state, source) {
const tiles = source.filter(Boolean).map(item => ({ ...item }))
if (tiles.length === ROWS * COLS) {
for (let attempt = 0; attempt < 300; attempt += 1) {
const candidate = shuffle(tiles, state.rng)
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
}
}
state.metrics.stableFallbacks += 1
return buildPlayableBoard(state)
}
function queueSafeGroupTransition(state, keys) {
const target = [...keys]
const last = state.queuedSafeGroups[state.queuedSafeGroups.length - 1]
if (!last || !last.every((key, index) => key === target[index])) {
state.queuedSafeGroups.push(target)
}
startNextTransition(state)
}
function startNextTransition(state) {
if (state.transition) return false
while (state.queuedSafeGroups.length) {
const target = state.queuedSafeGroups[0]
const slot = target.findIndex((key, index) => state.activeSafeKeys[index] !== key)
if (slot < 0) {
state.queuedSafeGroups.shift()
continue
}
const from = state.activeSafeKeys[slot]
const to = target[slot]
state.activeSafeKeys[slot] = to
state.transition = { from, to, slot, age: 0 }
return true
}
return false
}
function finishTransitionIfReady(state) {
let changed = false
while (state.transition) {
const { from, to } = state.transition
const remaining = state.board.filter(item => item?.key === from).length
if (remaining > 2) break
if (remaining) {
state.board = state.board.map(item => item?.key === from ? { ...item, key: to } : item)
state.metrics.tailConversions += remaining
}
state.transition = null
changed = true
startNextTransition(state)
}
return changed
}
function prepareTransitionForReshuffle(state) {
const activeSet = new Set(state.activeSafeKeys)
const counts = Object.fromEntries(state.activeSafeKeys.map(key => [key, 0]))
state.board.forEach(item => {
if (item && activeSet.has(item.key)) counts[item.key] += 1
})
let imported = 0
const next = state.board.map(item => {
if (!item || item.key === 'camel' || HIGH_KEYS.has(item.key)) return item ? { ...item } : item
if (state.transition && item.key === state.transition.from) {
imported += 1
counts[state.transition.to] += 1
return { ...item, key: state.transition.to }
}
if (activeSet.has(item.key)) return { ...item }
const key = [...state.activeSafeKeys].sort((a, b) => counts[a] - counts[b])[0]
counts[key] += 1
imported += 1
return { ...item, key }
})
state.board = next
state.metrics.reshuffleImports += imported
}
function randomFoodKey(state, snapshot, col) {
const highs = snapshot.filter(item => item && HIGH_KEYS.has(item.key)).length
if (highs < HIGH_CAP && state.rng() < DANGER_DROP_CHANCE) {
return FOOD_GROUPS[state.themeIndex].danger
}
if (state.rng() < COLUMN_GROUP_BIAS) return state.activeSafeKeys[col % state.activeSafeKeys.length]
return state.activeSafeKeys[Math.floor(state.rng() * state.activeSafeKeys.length)]
}
function pickDropKey(state, snapshot, index, col) {
for (let attempt = 0; attempt < 16; attempt += 1) {
const key = randomFoodKey(state, snapshot, col)
if (!wouldCreateMatchAt(snapshot, index, key)) return key
}
const fallback = [...state.activeSafeKeys, FOOD_GROUPS[state.themeIndex].danger]
.find(key => !wouldCreateMatchAt(snapshot, index, key))
return fallback || state.activeSafeKeys[(index + col) % state.activeSafeKeys.length]
}
function collapseAndFill(state) {
const next = Array(ROWS * COLS).fill(null)
for (let col = 0; col < COLS; col += 1) {
const existing = []
for (let row = ROWS - 1; row >= 0; row -= 1) {
const item = state.board[row * COLS + col]
if (item) existing.push(item)
}
existing.forEach((item, offset) => {
next[(ROWS - 1 - offset) * COLS + col] = item
})
}
for (let row = 0; row < ROWS; row += 1) {
for (let col = 0; col < COLS; col += 1) {
const index = row * COLS + col
if (!next[index]) next[index] = tile(pickDropKey(state, next, index, col))
}
}
state.board = next
}
function reshuffle(state) {
prepareTransitionForReshuffle(state)
let candidate = null
for (let attempt = 0; attempt < 240; attempt += 1) {
const shuffled = shuffle(state.board, state.rng)
if (findMatches(shuffled).length || !safeMoves(shuffled).length) continue
candidate = shuffled
break
}
state.board = candidate || buildPlayableBoard(state, forcedPlayableKeys(state))
finishTransitionIfReady(state)
state.metrics.reshuffles += 1
}
function advanceTheme(state) {
const previous = FOOD_GROUPS[state.themeIndex]
state.themeIndex = (state.themeIndex + 1) % FOOD_GROUPS.length
const next = FOOD_GROUPS[state.themeIndex]
state.board = state.board.map(item => item?.key === previous.danger ? { ...item, key: next.danger } : item)
queueSafeGroupTransition(state, next.safe)
state.metrics.themeChanges += 1
}
function resolveMatches(state, initialGroups) {
let groups = initialGroups
let wave = 0
let camelMatch = 0
while (groups.length && wave < MAX_CASCADE_WAVES) {
wave += 1
const remove = new Set(groups.flatMap(group => group.indices))
for (const group of groups) {
if (group.indices.every(index => state.board[index]?.key === 'camel')) {
camelMatch = Math.max(camelMatch, group.indices.length)
}
}
remove.forEach(index => { state.board[index] = null })
collapseAndFill(state)
finishTransitionIfReady(state)
groups = findMatches(state.board)
const autoHigh = groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))
if (autoHigh) {
state.metrics.automaticHighAvoided += 1
state.board = makeStablePlayableBoard(state, state.board)
groups = []
} else if (wave >= MAX_CASCADE_WAVES && groups.length) {
state.metrics.cascadeCaps += 1
state.board = makeStablePlayableBoard(state, state.board)
groups = []
}
}
state.metrics.maxCascade = Math.max(state.metrics.maxCascade, wave)
if (camelMatch >= 3) advanceTheme(state)
}
function useCamelTool(state) {
const danger = FOOD_GROUPS[state.themeIndex].danger
const targets = state.board
.map((item, index) => item?.key === danger ? index : -1)
.filter(index => index >= 0)
if (!targets.length) return false
const index = targets[Math.floor(state.rng() * targets.length)]
state.board[index] = tile('camel')
state.metrics.toolUses += 1
const groups = findMatches(state.board)
if (groups.length) resolveMatches(state, groups)
return true
}
function recordFailure(state, type, detail = '') {
state.metrics.failures[type] = (state.metrics.failures[type] || 0) + 1
if (state.metrics.samples.length < 20) {
state.metrics.samples.push({ run: state.run, turn: state.turn, type, detail, board: state.board.map(item => item?.key || '-') })
}
}
function validateState(state, expectStable = true) {
if (state.board.length !== ROWS * COLS || state.board.some(item => !item?.key)) {
recordFailure(state, 'invalid_board_size')
return false
}
const currentDanger = FOOD_GROUPS[state.themeIndex].danger
const highKeys = state.board.filter(item => HIGH_KEYS.has(item.key)).map(item => item.key)
if (highKeys.some(key => key !== currentDanger)) recordFailure(state, 'wrong_theme_danger', highKeys.join(','))
if (highKeys.length > HIGH_CAP) recordFailure(state, 'danger_cap_exceeded', String(highKeys.length))
const allowed = new Set([...state.activeSafeKeys, 'camel'])
if (state.transition) allowed.add(state.transition.from)
const stale = state.board.filter(item => !HIGH_KEYS.has(item.key) && !allowed.has(item.key)).map(item => item.key)
if (stale.length) recordFailure(state, 'stale_food', stale.join(','))
const normalTypes = new Set(state.board.filter(item => SAFE_KEYS.has(item.key)).map(item => item.key))
if (normalTypes.size > 5) recordFailure(state, 'too_many_normal_types', String(normalTypes.size))
if (new Set(state.activeSafeKeys).size !== 4) recordFailure(state, 'duplicate_active_food')
if (state.transition) {
const remaining = state.board.filter(item => item.key === state.transition.from).length
if (remaining <= 2) recordFailure(state, 'orphan_transition_tail', `${state.transition.from}:${remaining}`)
}
if (expectStable && findMatches(state.board).length) recordFailure(state, 'unresolved_match')
return true
}
function createMetrics() {
return {
runs: RUNS,
turnsPerRun: TURNS,
totalTurns: 0,
validMoves: 0,
reshuffles: 0,
reshuffleImports: 0,
guaranteedFallbacks: 0,
stableFallbacks: 0,
themeChanges: 0,
toolUses: 0,
tailConversions: 0,
automaticHighAvoided: 0,
cascadeCaps: 0,
maxCascade: 0,
unsafeHighMoveProbes: 0,
maxTransitionAge: 0,
maxQueuedThemes: 0,
transitionDrainTurns: 0,
unfinishedTransitions: 0,
failures: {},
samples: []
}
}
function runTargetedTailTests(metrics) {
for (let groupIndex = 0; groupIndex < FOOD_GROUPS.length; groupIndex += 1) {
const nextIndex = (groupIndex + 1) % FOOD_GROUPS.length
const previousGroup = FOOD_GROUPS[groupIndex]
const nextGroup = FOOD_GROUPS[nextIndex]
// 相邻主题允许在相同槽位保留同一种食物。定向测试应选择真正退出下一组的食物,
// 不能固定测试第 0 槽,否则“牛奶→牛奶”会被误报为尾巴未转换。
const slot = previousGroup.safe.findIndex(key => !nextGroup.safe.includes(key))
if (slot < 0) continue
for (const remaining of [0, 1, 2]) {
const state = {
run: -1,
turn: remaining,
rng: createRng(BASE_SEED + groupIndex * 17 + remaining),
metrics,
themeIndex: nextIndex,
activeSafeKeys: [...nextGroup.safe],
transition: {
from: previousGroup.safe[slot],
to: nextGroup.safe[slot],
slot,
age: 0
},
queuedSafeGroups: [[...nextGroup.safe]],
board: []
}
const fill = nextGroup.safe
state.board = Array.from({ length: ROWS * COLS }, (_, index) => tile(fill[index % fill.length]))
for (let index = 0; index < remaining; index += 1) state.board[index] = tile(state.transition.from)
finishTransitionIfReady(state)
if (state.board.some(item => item.key === state.transition?.from || item.key === previousGroup.safe[slot])) {
recordFailure(state, 'targeted_tail_not_converted', `${groupIndex}:${remaining}`)
}
}
}
}
const metrics = createMetrics()
runTargetedTailTests(metrics)
for (let run = 0; run < RUNS; run += 1) {
const state = {
run,
turn: 0,
rng: createRng(BASE_SEED + run * 2654435761),
metrics,
themeIndex: run % FOOD_GROUPS.length,
activeSafeKeys: [...FOOD_GROUPS[run % FOOD_GROUPS.length].safe],
transition: null,
queuedSafeGroups: [],
board: []
}
state.board = buildPlayableBoard(state)
validateState(state)
let toolsUsed = 0
for (let turn = 0; turn < TURNS; turn += 1) {
state.turn = turn
metrics.totalTurns += 1
if (state.transition) {
state.transition.age += 1
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
}
metrics.maxQueuedThemes = Math.max(metrics.maxQueuedThemes, state.queuedSafeGroups.length)
// 模拟玩家积攒到驼乳后替换高糖,但每局限制5次,避免测试人为填满棋盘。
if (toolsUsed < 5 && turn > 0 && turn % 13 === 0 && useCamelTool(state)) {
toolsUsed += 1
finishTransitionIfReady(state)
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
}
let moves = safeMoves(state.board)
if (!moves.length) {
reshuffle(state)
moves = safeMoves(state.board)
}
if (!moves.length) {
recordFailure(state, 'dead_board_after_reshuffle')
break
}
// 额外探测“主动三消高糖会结束”的判定,不改变主模拟棋盘。
const unsafe = enumerateMatchingMoves(state.board).find(move => move.hitsHigh)
if (unsafe) {
const probe = swap(state.board, unsafe.from, unsafe.to)
const direct = findMatches(probe)
if (!direct.some(group => group.indices.some(index => HIGH_KEYS.has(probe[index]?.key)))) {
recordFailure(state, 'unsafe_high_probe_missed')
}
metrics.unsafeHighMoveProbes += 1
}
const move = moves[Math.floor(state.rng() * moves.length)]
state.board = swap(state.board, move.from, move.to)
const groups = findMatches(state.board)
if (!groups.length) {
recordFailure(state, 'safe_move_without_match')
break
}
if (groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))) {
recordFailure(state, 'safe_move_hit_high')
break
}
resolveMatches(state, groups)
metrics.validMoves += 1
// 用较快节奏覆盖连续换组和排队逻辑,强度高于正常玩家。
if ((turn + 1) % 16 === 0 && turn + 1 < TURNS) advanceTheme(state)
finishTransitionIfReady(state)
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
if (!safeMoves(state.board).length) reshuffle(state)
validateState(state)
}
// 停止新增主题后继续正常消除,验证排队中的旧食物能否全部退出,避免队列永久积压。
let drainTurn = 0
while ((state.transition || state.queuedSafeGroups.length) && drainTurn < 80) {
state.turn = TURNS + drainTurn
if (state.transition) {
state.transition.age += 1
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
}
let moves = safeMoves(state.board)
if (!moves.length) {
reshuffle(state)
moves = safeMoves(state.board)
}
if (!moves.length) {
recordFailure(state, 'dead_board_while_draining')
break
}
const move = moves[Math.floor(state.rng() * moves.length)]
state.board = swap(state.board, move.from, move.to)
resolveMatches(state, findMatches(state.board))
metrics.validMoves += 1
finishTransitionIfReady(state)
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
if (!safeMoves(state.board).length) reshuffle(state)
validateState(state)
drainTurn += 1
}
metrics.transitionDrainTurns += drainTurn
if (state.transition || state.queuedSafeGroups.length) metrics.unfinishedTransitions += 1
}
const failureCount = Object.values(metrics.failures).reduce((sum, count) => sum + count, 0)
const result = {
ok: failureCount === 0,
...metrics,
failureCount
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
if (!result.ok) process.exitCode = 1
@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import type { Plugin } from 'vite'
const FOOD_ICON_FILES = [
'tofu.jpg',
'spinach.jpg',
'carrot.jpg',
'fish.jpg',
'redBeanCongee.jpg',
'milkTea.jpg'
]
/**
* Keep endless-game food icons inside the tongji subpackage.
* Importing them from Vue would make Vite emit them into the main-package assets folder.
*/
export function tongjiFoodAssetsPlugin(): Plugin {
return {
name: 'tongji-food-assets',
apply: 'build',
generateBundle() {
FOOD_ICON_FILES.forEach((fileName) => {
this.emitFile({
type: 'asset',
fileName: `tongji/endless-game/assets/food/${fileName}`,
source: readFileSync(resolve(process.cwd(), 'tongji/endless-game/assets/food', fileName))
})
})
}
}
}
@@ -0,0 +1,71 @@
/**
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
*/
export function useGameAuth(proxy) {
let loginPromise = null
function hasToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearToken() {
uni.removeStorageSync('token')
}
function wxLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
fail: reject
})
})
}
async function loginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res?.code !== 1 || !res.data?.token) {
clearToken()
throw new Error(res?.msg || '登录失败')
}
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return true
}
async function verifyOrLogin() {
if (hasToken()) {
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res?.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
if (res?.code !== -1) return true
} catch (_) {
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
return true
}
clearToken()
}
const code = await wxLoginCode()
return loginWithCode(code)
}
async function ensureLoggedIn() {
if (loginPromise) return loginPromise
loginPromise = verifyOrLogin()
.then(ok => !!ok)
.catch(() => false)
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
.finally(() => { loginPromise = null })
return loginPromise
}
return { ensureLoggedIn }
}
@@ -0,0 +1,265 @@
import { ref } from 'vue'
const EMPTY_BOARD = {
week_start: '',
week_end: '',
sex_label: '同行',
group_size: 7,
member_count: 0,
players: [],
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
invite_code: ''
}
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
function createSessionKey() {
const random = Math.random().toString(36).slice(2, 12)
return `game_${Date.now().toString(36)}_${random}`
}
function readPendingPayloads() {
try {
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
if (!Array.isArray(stored)) return []
return stored.filter(item => (
item
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
&& Number(item.learned_count) >= 0
)).slice(-8)
} catch (_) {
return []
}
}
/**
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
*/
export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const lastError = ref('')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
let sessionKey = createSessionKey()
let syncTimer = null
let syncing = false
let queuedPayloads = readPendingPayloads()
let connectPromise = null
function persistPendingPayloads() {
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
}
async function api(request) {
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
return proxy.apiUrl(request, false)
}
function applyLeaderboard(data, confirmedForSession = '') {
if (!data || !Array.isArray(data.players)) return
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
if (!inactiveSessionResponse) {
leaderboard.value = {
...EMPTY_BOARD,
...data,
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
players: data.players
}
}
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
}
connected.value = true
syncStatus.value = 'synced'
lastError.value = ''
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET',
data: { session_key: sessionKey }
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data, sessionKey)
return res.data
}
async function acceptShare(inviteCode) {
const code = String(inviteCode || '').trim().toUpperCase()
if (!code) return
try {
await api({
url: '/api/tcm/gameAcceptShare',
method: 'POST',
data: { invite_code: code }
})
} catch (_) {
// 分享关系是辅助能力,不阻断进入游戏。
}
}
async function connect(inviteCode = '') {
if (connectPromise) return connectPromise
connectPromise = (async () => {
loading.value = true
try {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('登录失败')
await acceptShare(inviteCode)
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (error) {
connected.value = false
syncStatus.value = 'offline'
lastError.value = String(error?.message || '平台连接失败,请点击重试')
return false
} finally {
loading.value = false
connectPromise = null
}
})()
return connectPromise
}
function beginSession(existingSessionKey = '') {
if (syncTimer) clearTimeout(syncTimer)
const restoredKey = String(existingSessionKey || '').trim()
sessionKey = /^[A-Za-z0-9_-]{16,64}$/.test(restoredKey)
? restoredKey
: createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function getSessionKey() {
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
learned_count: Math.max(0, Number(learnedCount) || 0),
score: Math.max(0, Number(score) || 0),
ended: ended ? 1 : 0
}
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
if (existingIndex >= 0) {
const existing = queuedPayloads[existingIndex]
queuedPayloads[existingIndex] = {
...nextPayload,
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
score: Math.max(existing.score, nextPayload.score),
ended: Math.max(existing.ended, nextPayload.ended)
}
} else {
queuedPayloads.push(nextPayload)
}
persistPendingPayloads()
syncStatus.value = 'pending'
if (syncTimer) clearTimeout(syncTimer)
if (ended) {
flushProgress()
} else {
syncTimer = setTimeout(flushProgress, 1000)
}
}
async function flushProgress() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
if (syncing || !queuedPayloads.length) return
const payload = queuedPayloads[0]
syncing = true
syncStatus.value = 'syncing'
try {
if (!connected.value) {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('未登录')
}
const res = await api({
url: '/api/tcm/gameSubmitProgress',
method: 'POST',
data: payload
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '成绩保存失败')
}
applyLeaderboard(res.data, payload.session_key)
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
queuedPayloads = queuedPayloads.filter(item => (
item.session_key !== payload.session_key
|| Number(item.learned_count) > Number(payload.learned_count)
|| Number(item.score) > Number(payload.score)
|| Number(item.ended) > Number(payload.ended)
))
persistPendingPayloads()
} catch (error) {
connected.value = false
syncStatus.value = 'offline'
lastError.value = String(error?.message || '成绩暂未保存,联网后会自动重试')
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
syncing = false
if (queuedPayloads.length && connected.value) {
setTimeout(flushProgress, 80)
}
}
}
async function syncAndRefresh(learnedCount, score) {
queueProgress(learnedCount, score, false)
await flushProgress()
if (!connected.value) {
await connect()
if (queuedPayloads.length) await flushProgress()
} else {
try { await refreshLeaderboard() } catch (_) {}
}
}
async function recordShare() {
try {
if (!connected.value && !(await connect())) return
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
if (res?.code === 1 && res.data?.invite_code) {
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
}
} catch (_) {
// 分享本身仍可进行,统计失败不影响用户。
}
}
function dispose() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
persistPendingPayloads()
}
return {
connected,
loading,
syncStatus,
lastError,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
getSessionKey,
queueProgress,
flushProgress,
syncAndRefresh,
refreshLeaderboard,
recordShare,
dispose
}
}
@@ -25,6 +25,13 @@ const SRC = {
* rate 不同可在同一素材上听出明显差异 * rate 不同可在同一素材上听出明显差异
*/ */
const SFX = { const SFX = {
// —— 基础素材别名(供分层音效直接调用)——
tap: { src: SRC.tap, volume: 0.32, rate: 1, pool: 3 },
pop: { src: SRC.pop, volume: 0.3, rate: 1, pool: 4 },
fanfare: { src: SRC.fanfare, volume: 0.56, rate: 1, pool: 2 },
blast: { src: SRC.blast, volume: 0.62, rate: 1, pool: 2 },
sparkle: { src: SRC.sparkle, volume: 0.5, rate: 1, pool: 2 },
// —— 交互 —— // —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 }, select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 }, deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
@@ -77,6 +84,43 @@ const SFX = {
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 } danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
} }
const WARMUP_SFX = [
'select', 'swap', 'swapFail', 'drop', 'dropLight',
'match3', 'match4', 'match5', 'combo2', 'combo3', 'comboMega',
'insulin', 'sparkle', 'reshuffle', 'lose'
]
function createAudioContext() {
if (typeof Audio !== 'undefined') {
const audio = new Audio()
audio.preload = 'auto'
return {
get src() { return audio.src },
set src(value) { audio.src = value },
get volume() { return audio.volume },
set volume(value) { audio.volume = value },
get playbackRate() { return audio.playbackRate },
set playbackRate(value) { audio.playbackRate = value },
play() {
const promise = audio.play()
if (promise?.catch) promise.catch(() => {})
},
stop() {
audio.pause()
try { audio.currentTime = 0 } catch (_) {}
},
seek(time) {
try { audio.currentTime = time } catch (_) {}
},
destroy() {
audio.pause()
audio.removeAttribute('src')
}
}
}
return uni.createInnerAudioContext()
}
class SfxPool { class SfxPool {
constructor(src, size, volume, rate = 1) { constructor(src, size, volume, rate = 1) {
this.src = src this.src = src
@@ -88,9 +132,8 @@ class SfxPool {
this.warmedUp = false this.warmedUp = false
} }
create() { createOne() {
for (let i = 0; i < this.size; i++) { const ctx = createAudioContext()
const ctx = uni.createInnerAudioContext()
ctx.src = this.src ctx.src = this.src
ctx.obeyMuteSwitch = false ctx.obeyMuteSwitch = false
ctx.autoplay = false ctx.autoplay = false
@@ -99,13 +142,16 @@ class SfxPool {
ctx.playbackRate = this.rate ctx.playbackRate = this.rate
} catch (_) {} } catch (_) {}
this.list.push(ctx) this.list.push(ctx)
return ctx
} }
create() {
while (this.list.length < this.size) this.createOne()
} }
warmUp() { warmUp() {
if (this.warmedUp) return if (this.warmedUp) return
if (!this.list.length) this.create() const ctx = this.list[0] || this.createOne()
this.list.forEach((ctx) => {
try { try {
ctx.volume = 0 ctx.volume = 0
ctx.play() ctx.play()
@@ -116,12 +162,12 @@ class SfxPool {
} catch (_) {} } catch (_) {}
}, 60) }, 60)
} catch (_) {} } catch (_) {}
}) if (this.size > 1) this.cursor = 1
this.warmedUp = true this.warmedUp = true
} }
play(scale = 1, rateMul = 1) { play(scale = 1, rateMul = 1) {
if (!this.list.length) this.create() if (this.list.length < this.size) this.create()
const ctx = this.list[this.cursor % this.list.length] const ctx = this.list[this.cursor % this.list.length]
this.cursor += 1 this.cursor += 1
const vol = Math.min(1, this.volume * scale) const vol = Math.min(1, this.volume * scale)
@@ -204,7 +250,8 @@ export function useGameSfx() {
function warmUp() { function warmUp() {
if (!enabled.value) return if (!enabled.value) return
ensureAudioOption() ensureAudioOption()
Object.keys(SFX).forEach((name) => { const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
names.forEach((name) => {
getPool(name)?.warmUp() getPool(name)?.warmUp()
}) })
} }
@@ -0,0 +1,17 @@
# 识糖小课堂 3D 食物图标候选稿 v1
- 共 42 个食物图标,文件名与 `FOODS` 中的英文键名一致。
- 单图尺寸:320 × 320JPEG。
- 风格:中式、圆润 3D、暖色奶油背景、大轮廓、适老化高辨识度。
- 糖级、边框和角标不画入图标,由游戏界面控制,方便医生调整分类。
- `milkTea.jpg` 是唯一自带角色表情的高糖小反派候选。
- 驼乳属于产品道具,不计入 42 个普通食物图标,本轮未替换现有产品罐素材。
- 本目录当前仅作设计候选,没有接入正式游戏,也不会进入微信小程序发布包。
目录说明:
- `icons/`:可直接用于游戏的独立图标。
- `sheets/`:11 张分组预览母版,方便整体审美检查。
- `manifest.json`:食物中文名、代码键名与文件路径清单。
医学提示:图标仅表达食物外观,不代表最终糖级结论;糖级和健康说明仍需甄养堂医生审核。
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,53 @@
{
"version": 1,
"style": "chinese-rounded-3d-elderly-friendly",
"iconSize": {
"width": 320,
"height": 320,
"format": "jpg"
},
"items": [
{ "key": "corn", "name": "玉米", "file": "icons/corn.jpg" },
{ "key": "egg", "name": "鸡蛋", "file": "icons/egg.jpg" },
{ "key": "tomato", "name": "番茄", "file": "icons/tomato.jpg" },
{ "key": "potato", "name": "红薯", "file": "icons/potato.jpg" },
{ "key": "cucumber", "name": "黄瓜", "file": "icons/cucumber.jpg" },
{ "key": "lettuce", "name": "生菜", "file": "icons/lettuce.jpg" },
{ "key": "grainMantou", "name": "杂粮馒头", "file": "icons/grainMantou.jpg" },
{ "key": "wonton", "name": "馄饨", "file": "icons/wonton.jpg" },
{ "key": "celery", "name": "芹菜", "file": "icons/celery.jpg" },
{ "key": "mushroom", "name": "香菇", "file": "icons/mushroom.jpg" },
{ "key": "chicken", "name": "鸡肉", "file": "icons/chicken.jpg" },
{ "key": "taro", "name": "芋头", "file": "icons/taro.jpg" },
{ "key": "broccoli", "name": "西兰花", "file": "icons/broccoli.jpg" },
{ "key": "milk", "name": "牛奶", "file": "icons/milk.jpg" },
{ "key": "brownRice", "name": "糙米饭", "file": "icons/brownRice.jpg" },
{ "key": "apple", "name": "苹果", "file": "icons/apple.jpg" },
{ "key": "strawberry", "name": "草莓", "file": "icons/strawberry.jpg" },
{ "key": "banana", "name": "香蕉", "file": "icons/banana.jpg" },
{ "key": "mango", "name": "芒果", "file": "icons/mango.jpg" },
{ "key": "tea", "name": "红茶", "file": "icons/tea.jpg" },
{ "key": "blackCoffee", "name": "黑咖啡", "file": "icons/blackCoffee.jpg" },
{ "key": "coffee", "name": "咖啡", "file": "icons/coffee.jpg" },
{ "key": "pineapple", "name": "菠萝", "file": "icons/pineapple.jpg" },
{ "key": "pepper", "name": "青椒", "file": "icons/pepper.jpg" },
{ "key": "greenBean", "name": "四季豆", "file": "icons/greenBean.jpg" },
{ "key": "shrimp", "name": "虾仁", "file": "icons/shrimp.jpg" },
{ "key": "udon", "name": "乌冬面", "file": "icons/udon.jpg" },
{ "key": "onion", "name": "洋葱", "file": "icons/onion.jpg" },
{ "key": "congee", "name": "白米粥", "file": "icons/congee.jpg" },
{ "key": "eightCongee", "name": "八宝粥", "file": "icons/eightCongee.jpg" },
{ "key": "centuryCongee", "name": "皮蛋瘦肉粥", "file": "icons/centuryCongee.jpg" },
{ "key": "milletCongee", "name": "小米粥", "file": "icons/milletCongee.jpg" },
{ "key": "orangeJuice", "name": "柳橙汁", "file": "icons/orangeJuice.jpg" },
{ "key": "soda", "name": "含糖汽水", "file": "icons/soda.jpg" },
{ "key": "riceNoodleSoup", "name": "汤粉", "file": "icons/riceNoodleSoup.jpg" },
{ "key": "noodleSoup", "name": "汤面", "file": "icons/noodleSoup.jpg" },
{ "key": "milkTea", "name": "含糖奶茶", "file": "icons/milkTea.jpg" },
{ "key": "tofu", "name": "豆腐", "file": "icons/tofu.jpg" },
{ "key": "spinach", "name": "菠菜", "file": "icons/spinach.jpg" },
{ "key": "carrot", "name": "胡萝卜", "file": "icons/carrot.jpg" },
{ "key": "fish", "name": "鱼", "file": "icons/fish.jpg" },
{ "key": "redBeanCongee", "name": "软烂红豆粥", "file": "icons/redBeanCongee.jpg" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -0,0 +1,653 @@
.eg-page {
min-height: 100vh;
color: #173b32;
background: linear-gradient(180deg, #eefbf4 0%, #f9f3dd 56%, #fffaf1 100%);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.eg-confetti-layer { position: fixed; z-index: 80; inset: 0; overflow: hidden; pointer-events: none; }
.eg-confetti-piece {
position: absolute;
top: -70rpx;
display: block;
border-radius: 3rpx;
box-shadow: 0 2rpx 4rpx rgba(0,0,0,.12);
animation-name: egConfettiFall;
animation-timing-function: cubic-bezier(.18,.72,.35,1);
animation-fill-mode: both;
}
.eg-confetti-piece.is-emoji { width: auto !important; height: auto !important; background: transparent !important; box-shadow: none; font-size: 42rpx; }
.eg-nav {
position: relative;
display: grid;
box-sizing: border-box;
grid-template-columns: 156rpx minmax(0, 1fr) 156rpx;
align-items: center;
column-gap: 12rpx;
padding-right: 28rpx;
padding-bottom: 16rpx;
padding-left: 28rpx;
}
.eg-nav-leading { display: flex; width: 156rpx; justify-content: flex-start; }
.eg-nav-btn {
display: flex;
width: 72rpx;
height: 72rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(21, 94, 75, .12);
border-radius: 24rpx;
background: rgba(255,255,255,.82);
}
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
.eg-title-wrap { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: center; }
.eg-title-wrap > .eg-title,
.eg-title-wrap > .eg-subtitle { transition: opacity .08s ease; }
.eg-title-wrap.is-noticing > .eg-title,
.eg-title-wrap.is-noticing > .eg-subtitle { opacity: .16; }
.eg-nav-notice {
position: absolute;
z-index: 1;
top: 50%;
left: 50%;
display: flex;
width: 100%;
min-width: 0;
box-sizing: border-box;
flex-direction: column;
align-items: center;
padding: 5rpx 8rpx;
border: 1rpx solid rgba(21,94,75,.09);
border-radius: 16rpx;
color: rgba(38,86,72,.82);
background: rgba(255,255,255,.58);
box-shadow: 0 4rpx 12rpx rgba(13,64,51,.06);
text-align: center;
transform: translate(-50%, -50%);
animation: egNavNotice 1.05s ease-out both;
pointer-events: none;
}
.eg-nav-notice-title,
.eg-nav-notice-detail {
display: block;
overflow: hidden;
max-width: 100%;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.eg-nav-notice-title { font-size: 23rpx; font-weight: 800; }
.eg-nav-notice-detail { margin-top: 2rpx; color: rgba(154,106,42,.78); font-size: 20rpx; font-weight: 700; }
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
.eg-content { padding: 0 16rpx 48rpx; }
.eg-score-card {
display: flex;
align-items: center;
margin-bottom: 16rpx;
padding: 18rpx 24rpx;
border: 2rpx solid rgba(21, 94, 75, .1);
border-radius: 28rpx;
background: rgba(255,255,255,.9);
box-shadow: 0 10rpx 28rpx rgba(32, 78, 43, .08);
}
.eg-stat { display: flex; flex: 1; flex-direction: column; align-items: center; }
.eg-stat--main { border-right: 2rpx solid #e2eee8; border-left: 2rpx solid #e2eee8; }
.eg-stat-label { color: #71847d; font-size: 24rpx; }
.eg-stat-value { margin-top: 4rpx; color: #204e2b; font-size: 38rpx; font-weight: 800; }
.eg-stat-score { margin-top: 2rpx; color: #e16f24; font-size: 46rpx; font-weight: 900; }
.eg-stat--rank { position: relative; cursor: pointer; }
.eg-stat--rank.is-locked { opacity: .52; }
.eg-stat-rank-value { margin-top: 1rpx; color: #176b52; font-size: 35rpx; font-weight: 1000; line-height: 1.08; }
.eg-stat-rank-hint { margin-top: 2rpx; color: #df7427; font-size: 18rpx; font-weight: 800; }
.eg-task-card {
margin-bottom: 16rpx;
padding: 18rpx 22rpx;
border-radius: 24rpx;
background: rgba(255, 249, 223, .94);
box-shadow: 0 8rpx 22rpx rgba(118, 89, 25, .08);
}
.eg-task-card { position: relative; overflow: visible; }
.eg-task-reward-flight {
position: absolute;
z-index: 72;
top: 4rpx;
left: 42%;
display: flex;
width: 112rpx;
height: 92rpx;
align-items: center;
justify-content: center;
border: 4rpx solid rgba(255,255,255,.96);
border-radius: 24rpx;
background: #fff7e8;
box-shadow: 0 10rpx 24rpx rgba(159,94,23,.28);
animation: egTaskRewardFlight 1.12s cubic-bezier(.18,.78,.22,1) both;
pointer-events: none;
}
.eg-task-reward-flight > image { position: relative; z-index: 2; width: 78rpx; height: 66rpx; }
.eg-task-reward-flight > text { position: absolute; z-index: 3; top: -14rpx; right: -16rpx; display: flex; width: 48rpx; height: 48rpx; align-items: center; justify-content: center; border: 4rpx solid #fff; border-radius: 50%; color: #fff; background: #ed7625; box-shadow: 0 5rpx 12rpx rgba(191,75,19,.3); font-size: 25rpx; font-weight: 1000; }
.eg-task-reward-glow { position: absolute; z-index: 1; inset: -15rpx; border-radius: 32rpx; background: radial-gradient(circle, rgba(255,210,67,.52), rgba(255,210,67,0) 68%); animation: egRewardGlow .42s ease-in-out infinite alternate; }
.eg-task-head { display: flex; align-items: center; justify-content: space-between; color: #7c5b1b; font-size: 27rpx; font-weight: 700; }
.eg-task-name { display: flex; align-items: center; gap: 10rpx; }
.eg-task-count { color: #9a6718; font-size: 29rpx; }
.eg-progress { height: 14rpx; margin-top: 12rpx; overflow: hidden; border-radius: 999rpx; background: #eadfbd; }
.eg-progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #f0a128, #f4c34f); transition: width .25s; }
.eg-board-shell {
position: relative;
padding: 14rpx;
border: 2rpx solid rgba(34, 103, 79, .16);
border-radius: 34rpx;
background: rgba(255,255,255,.92);
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
}
.eg-board-shell.is-flowing {
border-color: rgba(245, 158, 11, .72);
box-shadow: 0 0 0 5rpx rgba(255, 221, 89, .2), 0 18rpx 46rpx rgba(234, 88, 12, .2);
animation: egFlowShell 1.05s ease-in-out infinite alternate;
}
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
.eg-flow-state { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 14rpx; border: 3rpx solid #fff3a3; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #f97316, #dc2626); box-shadow: 0 5rpx 16rpx rgba(220,38,38,.3); font-size: 25rpx; font-weight: 1000; white-space: nowrap; animation: egFlowBadge .62s ease-in-out infinite alternate; }
.eg-flow-state > text:last-child { min-width: 62rpx; color: #fff7a8; text-align: right; }
.eg-flow-state.is-paused { border-color: #d9f6ff; background: linear-gradient(135deg, #1687b4, #3155a6); box-shadow: 0 5rpx 16rpx rgba(34,109,166,.28); animation: none; }
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
.eg-risk-dots { display: flex; gap: 5rpx; }
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
.eg-risk-dot.on { background: #e96b3b; box-shadow: 0 0 0 3rpx rgba(233,107,59,.12); }
.eg-board { display: flex; flex-direction: column; gap: 8rpx; opacity: 1; transition: opacity .15s; }
.eg-board.is-busy { opacity: .82; }
.eg-row { display: flex; gap: 8rpx; }
.eg-cell {
position: relative;
display: flex;
height: 166rpx;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
border: 5rpx solid transparent;
border-radius: 26rpx;
box-shadow: inset 0 -5rpx 0 rgba(0,0,0,.05), 0 5rpx 12rpx rgba(31, 66, 53, .08);
transition: transform .16s cubic-bezier(.2,.8,.2,1), border-color .15s, opacity .16s, filter .16s;
will-change: transform;
}
.eg-cell.is-dragging { transition: transform .055s linear, border-color .15s; }
.eg-cell.is-selected { z-index: 2; border-color: #167d61; transform: scale(1.06); box-shadow: 0 0 0 6rpx rgba(22,125,97,.14); }
.eg-cell.is-camel-target { border-color: #f59e0b; animation: egPulse 1s infinite; }
.eg-replace-target { position: absolute; z-index: 7; right: 7rpx; bottom: 6rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; background: #1570a6; box-shadow: 0 3rpx 9rpx rgba(20,91,135,.28); font-size: 20rpx; font-weight: 900; }
.eg-cell.is-clearing {
z-index: 8;
border-color: #fff;
animation: egHit .22s cubic-bezier(.2,.9,.3,1) forwards;
filter: brightness(1.28) saturate(1.25);
}
.eg-cell.is-yellow,
.eg-cell.is-cream,
.eg-cell.is-red,
.eg-cell.is-purple { background: #fffaf0; }
.eg-cell.is-level-low { background: #f4faf6; }
.eg-cell.is-level-mid { background: #fff9eb; }
.eg-cell.is-orange,
.eg-cell.is-level-high { background: #fff0e4; }
.eg-cell.is-milk { background: #edf7ff; }
.eg-cell.is-high { border-color: #ef7b45; }
.eg-cell.is-villain {
background: radial-gradient(circle at 50% 38%, #fff7e8 0%, #ffe9d8 70%, #ffddc6 100%);
box-shadow: inset 0 -5rpx 0 rgba(154,57,25,.08), 0 6rpx 15rpx rgba(191,72,31,.16);
}
.eg-cell.is-villain .eg-food-image { animation: egVillainPeek 2.2s ease-in-out infinite; }
.eg-cell.is-villain.is-clearing .eg-food-image,
.eg-cell.is-villain.is-dragging .eg-food-image { animation: none; }
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
.eg-food-image.is-large-square { width: 136rpx; height: 102rpx; margin-top: 2rpx; filter: saturate(1.1) contrast(1.06); }
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
.eg-sugar-tag.is-mid { color: #735a15; border-color: rgba(255,255,255,.9); background: #f7e7a8; box-shadow: 0 2rpx 6rpx rgba(137,91,0,.15); }
.eg-sugar-tag.is-high { background: #d9431f; box-shadow: 0 4rpx 10rpx rgba(163,45,21,.32); }
.eg-sugar-tag.is-prop { background: #2563a8; }
.eg-special-mark { position: absolute; z-index: 12; bottom: 5rpx; left: 6rpx; display: flex; width: 40rpx; height: 40rpx; align-items: center; justify-content: center; border: 3rpx solid rgba(255,255,255,.96); border-radius: 50%; color: #fff; background: #6d28d9; box-shadow: 0 4rpx 11rpx rgba(73,31,130,.3); font-size: 23rpx; font-weight: 900; }
.eg-special-mark.is-row { width: 58rpx; height: 40rpx; border-radius: 999rpx; background: linear-gradient(135deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
.eg-special-mark.is-col { width: 40rpx; height: 58rpx; border-radius: 999rpx; background: linear-gradient(180deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
.eg-special-mark.is-burst { width: 48rpx; height: 48rpx; border-color: #fff3a8; border-radius: 15rpx 50% 50%; background: linear-gradient(145deg, #7c3aed 5%, #dd3b70 48%, #ff8a18 100%); box-shadow: 0 4rpx 13rpx rgba(108,35,155,.38), 0 0 12rpx rgba(255,179,38,.3); animation: egLReady 1.25s ease-in-out infinite alternate; }
.eg-mini-flame { position: relative; width: 25rpx; height: 30rpx; border-radius: 70% 32% 68% 40%; background: linear-gradient(135deg, #fff8a8 8%, #ffc21f 42%, #f04420 84%); box-shadow: 0 0 14rpx rgba(255,230,92,.95); transform: rotate(43deg); }
.eg-mini-flame-core { position: absolute; right: 4rpx; bottom: 3rpx; width: 10rpx; height: 15rpx; border-radius: 70% 35% 70% 42%; background: #fffbd1; box-shadow: 0 0 7rpx rgba(255,255,210,.95); }
.eg-l-special { position: relative; width: 29rpx; height: 29rpx; filter: drop-shadow(0 1rpx 2rpx rgba(73,22,105,.3)); }
.eg-l-arm { position: absolute; left: 3rpx; bottom: 3rpx; border-radius: 999rpx; background: #fff9b8; box-shadow: 0 0 6rpx rgba(255,249,184,.92); }
.eg-l-arm.is-vertical { width: 8rpx; height: 25rpx; }
.eg-l-arm.is-horizontal { width: 25rpx; height: 8rpx; }
.eg-l-core { position: absolute; left: 1rpx; bottom: 1rpx; width: 12rpx; height: 12rpx; border: 2rpx solid #fff; border-radius: 50%; background: #ff5b25; }
.eg-cell.is-fire-clearing { border-color: #ff7a1a; box-shadow: inset 0 0 25rpx rgba(255,102,18,.36), 0 0 18rpx rgba(255,105,20,.48); filter: brightness(1.35) saturate(1.42); }
.eg-cell.is-clearing.is-fire-clearing { animation: egFireCellHit .36s cubic-bezier(.2,.78,.22,1) forwards; }
.eg-fire-clear { position: absolute; z-index: 14; inset: 2rpx; overflow: hidden; border: 3rpx solid rgba(255,210,71,.92); border-radius: 22rpx; opacity: .96; background: linear-gradient(180deg, rgba(255,230,86,.12), rgba(239,63,27,.22)); pointer-events: none; }
.eg-fire-glow { position: absolute; inset: 6%; border-radius: 18rpx; background: radial-gradient(circle, rgba(255,246,177,.74), rgba(255,131,22,.32) 47%, rgba(222,47,23,0) 75%); animation: egFireGlow .34s ease-out both; }
.eg-fire-sweep { position: absolute; top: 23%; left: -48%; width: 168%; height: 55%; border-radius: 60% 45% 55% 42%; background: linear-gradient(90deg, rgba(255,184,38,0), rgba(255,238,124,.94) 28%, rgba(255,133,22,.92) 53%, rgba(226,54,25,.82) 72%, rgba(255,184,38,0)); box-shadow: 0 0 27rpx rgba(255,91,17,.72); animation: egFireSweep .34s cubic-bezier(.2,.78,.22,1) both; }
.eg-fire-flame { position: absolute; bottom: 13%; width: 25rpx; height: 39rpx; border-radius: 72% 35% 68% 42%; background: linear-gradient(145deg, #fff7a6 4%, #ffc329 39%, #ff7419 66%, #dc321f 100%); box-shadow: 0 0 15rpx rgba(255,124,21,.88); transform: rotate(42deg); animation: egFireFlame .34s ease-out both; }
.eg-fire-flame > view { position: absolute; right: 5rpx; bottom: 4rpx; width: 10rpx; height: 18rpx; border-radius: 70% 35% 70% 42%; background: #fffbd6; }
.eg-fire-flame.is-left { left: 16%; animation-delay: .01s; }
.eg-fire-flame.is-center { left: 43%; bottom: 19%; transform: rotate(42deg) scale(1.18); animation-delay: .035s; }
.eg-fire-flame.is-right { right: 15%; animation-delay: .065s; }
.eg-fire-spark { position: absolute; width: 10rpx; height: 17rpx; border-radius: 70% 35% 70% 42%; background: #ffe45e; box-shadow: 0 0 11rpx rgba(255,139,28,.9); animation: egFireSpark .34s ease-out both; }
.eg-fire-spark.is-one { top: 46%; left: 25%; }
.eg-fire-spark.is-two { top: 34%; left: 62%; animation-delay: .035s; }
.eg-fire-spark.is-three { top: 52%; left: 78%; animation-delay: .07s; }
.eg-board-tip { display: flex; align-items: center; justify-content: center; gap: 8rpx; min-height: 58rpx; margin-top: 12rpx; color: #684817; font-size: 26rpx; font-weight: 700; text-align: center; }
.eg-impact-text {
position: absolute;
z-index: 20;
top: 46%;
left: 50%;
padding: 10rpx 24rpx;
border: 4rpx solid rgba(255,255,255,.9);
border-radius: 999rpx;
color: #fff;
background: linear-gradient(135deg, #f59e0b, #e4572e);
box-shadow: 0 10rpx 28rpx rgba(190, 70, 25, .35);
font-size: 34rpx;
font-weight: 900;
transform: translate(-50%, -50%);
animation: egImpact .52s cubic-bezier(.16,.82,.3,1) forwards;
pointer-events: none;
}
.eg-impact-text.is-level-2 { font-size: 40rpx; background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.eg-impact-text.is-level-3 { font-size: 46rpx; background: linear-gradient(135deg, #ef4444, #f59e0b); box-shadow: 0 12rpx 36rpx rgba(239,68,68,.42); }
.eg-jackpot {
position: absolute;
z-index: 30;
top: 48%;
left: 50%;
width: 540rpx;
padding: 24rpx 22rpx 20rpx;
overflow: hidden;
border: 7rpx solid #ffd83d;
border-radius: 36rpx;
color: #fff;
background: radial-gradient(circle at 50% 0%, #ef4444, #9f1239 65%, #64102a);
box-shadow: 0 0 0 7rpx #fff2a8, 0 22rpx 52rpx rgba(100,16,42,.5);
text-align: center;
transform: translate(-50%, -50%);
animation: egJackpotIn 1.35s cubic-bezier(.16,.85,.25,1) forwards;
pointer-events: none;
}
.eg-jackpot.is-match-4 { background: radial-gradient(circle at 50% 0%, #8b5cf6, #5b21b6 68%, #35106d); }
.eg-jackpot.is-match-5 { border-color: #fff36a; background: radial-gradient(circle at 50% 0%, #ff6b24, #d7193f 58%, #760c2a); animation-duration: 1.9s; }
.eg-jackpot.is-match-3 {
width: 414rpx;
padding: 20rpx 22rpx 18rpx;
border-width: 4rpx;
border-color: #f1c663;
color: #31574b;
background: linear-gradient(155deg, #f7fff9, #fff5d9);
box-shadow: 0 0 0 4rpx rgba(255,255,255,.84), 0 14rpx 34rpx rgba(98,82,39,.2);
animation: egTripleIn 1.12s cubic-bezier(.2,.82,.28,1) forwards;
}
.eg-jackpot.is-match-3 .eg-jackpot-lights { display: none; }
.eg-jackpot.is-match-3 .eg-jackpot-kicker { color: #5b8c73; font-size: 21rpx; letter-spacing: 3rpx; text-shadow: none; }
.eg-jackpot.is-match-3 .eg-jackpot-title { color: #365b4e; font-size: 31rpx; text-shadow: none; }
.eg-jackpot.is-match-3 .eg-jackpot-reward { color: #ad7021; font-size: 25rpx; }
.eg-jackpot.is-match-3 .eg-jackpot-note { color: #829087; }
.eg-triple-cans { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 12rpx 0 10rpx; }
.eg-triple-can { display: flex; width: 78rpx; height: 76rpx; align-items: center; justify-content: center; border: 3rpx solid #f4d996; border-radius: 19rpx; background: rgba(255,255,255,.9); box-shadow: 0 5rpx 12rpx rgba(128,92,30,.12); animation: egTripleCan .42s cubic-bezier(.2,.86,.26,1) both; }
.eg-triple-can:nth-child(2) { animation-delay: .07s; }
.eg-triple-can:nth-child(3) { animation-delay: .14s; }
.eg-triple-can > image { width: 62rpx; height: 58rpx; }
.eg-jackpot.is-match-4 {
width: 548rpx;
padding: 25rpx 22rpx 22rpx;
border-color: #ffd95b;
background: radial-gradient(circle at 50% -10%, #a97cff, #6031b5 58%, #341066);
box-shadow: 0 0 0 6rpx #fff0a9, 0 0 36rpx rgba(255,210,76,.5), 0 24rpx 58rpx rgba(53,16,109,.48);
animation: egFourIn 1.88s cubic-bezier(.16,.84,.24,1) forwards;
}
.eg-jackpot.is-match-4 .eg-jackpot-kicker { color: #ffef8d; font-size: 24rpx; letter-spacing: 3rpx; }
.eg-jackpot.is-match-4 .eg-jackpot-title { margin-top: 9rpx; color: #fff; font-size: 39rpx; }
.eg-jackpot.is-match-4 .eg-jackpot-reward { color: #fff2a8; }
.eg-four-teaser { position: relative; margin-top: 13rpx; }
.eg-four-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.eg-four-can { display: flex; width: 76rpx; height: 92rpx; align-items: center; justify-content: center; border: 4rpx solid #ffe078; border-radius: 18rpx; background: linear-gradient(180deg, #fffef7, #ffe9a5); box-shadow: inset 0 -5rpx 0 rgba(171,106,17,.12), 0 7rpx 13rpx rgba(34,12,67,.28); animation: egFourCan .48s cubic-bezier(.18,.9,.25,1.2) both; }
.eg-four-can:nth-child(2) { animation-delay: .07s; }
.eg-four-can:nth-child(3) { animation-delay: .14s; }
.eg-four-can:nth-child(4) { animation-delay: .21s; }
.eg-four-can > image { width: 64rpx; height: 70rpx; }
.eg-four-can.is-locked { border-style: dashed; border-color: rgba(255,241,161,.86); color: #fff6ae; background: rgba(48,18,91,.54); box-shadow: inset 0 0 16rpx rgba(255,225,91,.18), 0 0 18rpx rgba(255,222,91,.36); font-size: 48rpx; font-weight: 1000; animation: egLockedCan .68s .34s ease-in-out infinite alternate; }
.eg-four-promise { display: inline-flex; margin-top: 15rpx; padding: 8rpx 18rpx; border: 2rpx solid rgba(255,247,184,.72); border-radius: 999rpx; color: #3d176f; background: linear-gradient(90deg, #fff3a3, #ffd85b); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); font-size: 25rpx; font-weight: 1000; animation: egPromisePulse .72s ease-in-out infinite alternate; }
.eg-five-celebration { position: relative; margin: 15rpx 0 10rpx; }
.eg-five-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.eg-five-can { display: flex; width: 78rpx; height: 93rpx; align-items: center; justify-content: center; border: 4rpx solid #fff17b; border-radius: 18rpx; background: linear-gradient(180deg, #fff, #fff2ac); box-shadow: inset 0 -5rpx 0 rgba(184,91,12,.13), 0 7rpx 0 #bd5b13, 0 0 16rpx rgba(255,240,101,.5); animation: egFiveCan .56s cubic-bezier(.16,.92,.24,1.2) both; }
.eg-five-can:nth-child(2) { animation-delay: .07s; }
.eg-five-can:nth-child(3) { animation-delay: .14s; }
.eg-five-can:nth-child(4) { animation-delay: .21s; }
.eg-five-can:nth-child(5) { animation-delay: .28s; }
.eg-five-can > image { width: 65rpx; height: 72rpx; }
.eg-five-sevens { display: flex; justify-content: center; gap: 8rpx; margin-top: 13rpx; }
.eg-five-sevens > text { display: flex; width: 58rpx; height: 55rpx; align-items: center; justify-content: center; border: 3rpx solid #fff5a7; border-radius: 13rpx; color: #fff36a; background: linear-gradient(155deg, #d7193f, #9f1239); box-shadow: 0 5rpx 0 #70102b, 0 0 13rpx rgba(255,243,106,.45); font-size: 43rpx; font-weight: 1000; line-height: 1; text-shadow: 0 3rpx 0 #8f1733; }
.eg-jackpot-lights { position: absolute; inset: 8rpx; border: 4rpx dotted rgba(255,255,255,.88); border-radius: 25rpx; animation: egLights .24s steps(2) infinite; }
.eg-jackpot-kicker { position: relative; display: block; color: #fff36a; font-size: 28rpx; font-weight: 900; letter-spacing: 6rpx; text-shadow: 0 3rpx 0 rgba(83,19,25,.45); }
.eg-jackpot-reels { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 13rpx 0; }
.eg-jackpot-reel { display: flex; width: 112rpx; height: 126rpx; flex-direction: column; align-items: center; justify-content: center; border: 6rpx solid #ffcf31; border-radius: 20rpx; background: linear-gradient(180deg, #fff 0%, #fff7cf 48%, #ffd55e 50%, #fff 53%, #fff8dc 100%); box-shadow: inset 0 0 18rpx rgba(137,78,10,.25), 0 7rpx 0 #a85a0a; animation: egReelStop .52s cubic-bezier(.2,.9,.25,1) both; }
.eg-jackpot-reel:nth-child(2) { animation-delay: .09s; }
.eg-jackpot-reel:nth-child(3) { animation-delay: .18s; }
.eg-jackpot-seven { color: #e11d48; font-size: 61rpx; font-weight: 1000; line-height: .85; text-shadow: 0 3rpx 0 #ffd1d8; }
.eg-jackpot-milk { width: 54rpx; height: 36rpx; margin-top: 3rpx; border-radius: 7rpx; }
.eg-jackpot-title { position: relative; display: block; font-size: 37rpx; font-weight: 900; text-shadow: 0 4rpx 0 rgba(73,10,30,.5); }
.eg-jackpot-points { position: relative; display: block; margin-top: 2rpx; color: #fff36a; font-size: 66rpx; font-weight: 1000; line-height: 1.05; letter-spacing: 2rpx; text-shadow: 0 5rpx 0 #9e2813, 0 0 18rpx rgba(255,243,106,.85); animation: egPointsPop .62s .42s cubic-bezier(.16,.9,.25,1.25) both; }
.eg-jackpot-reward { position: relative; display: block; margin-top: 7rpx; color: #fff5a5; font-size: 27rpx; font-weight: 800; line-height: 1.35; }
.eg-jackpot-note { position: relative; display: block; margin-top: 8rpx; color: rgba(255,255,255,.8); font-size: 22rpx; }
.eg-tools { margin-top: 16rpx; }
.eg-tool { display: flex; align-items: center; padding: 16rpx 18rpx; border: 3rpx solid #c9e0d6; border-radius: 26rpx; background: rgba(255,255,255,.92); box-shadow: 0 7rpx 18rpx rgba(24, 84, 63, .07); }
.eg-tool.active { border-color: #f59e0b; background: #fffbeb; }
.eg-tool.disabled { opacity: .45; }
.eg-tool-icon { position: relative; display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; overflow: visible; border-radius: 24rpx; font-size: 46rpx; background: linear-gradient(145deg, #e8f7ff, #d4ecff); box-shadow: inset 0 -4rpx 0 rgba(33,104,151,.08); }
.eg-tool-food-image { width: 76rpx; height: 58rpx; border-radius: 14rpx; }
.eg-replace-badge { position: absolute; right: -12rpx; bottom: -8rpx; display: flex; min-width: 78rpx; height: 38rpx; align-items: center; justify-content: center; gap: 2rpx; padding: 0 8rpx; border: 3rpx solid #fff; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #2389b9, #145d91); box-shadow: 0 5rpx 12rpx rgba(20,93,145,.3); }
.eg-replace-badge .tj-icon-wrap { width: 22rpx; height: 22rpx; }
.eg-replace-badge > text { font-size: 20rpx; font-weight: 900; line-height: 1; }
.eg-tool-copy { display: flex; flex: 1; flex-direction: column; margin-left: 16rpx; }
.eg-tool-name { color: #244a3d; font-size: 29rpx; font-weight: 800; }
.eg-tool-desc { margin-top: 5rpx; color: #6c8179; font-size: 25rpx; }
.eg-tool-count { color: #155e4b; font-size: 32rpx; font-weight: 900; }
.eg-legend { display: flex; justify-content: center; gap: 18rpx; margin-top: 18rpx; color: #53685f; font-size: 25rpx; font-weight: 700; }
.eg-dot--safe { color: #4cae7b; }
.eg-dot--mid { color: #e2aa19; }
.eg-dot--high { color: #e4572e; }
.eg-weekly-overlay { position: fixed; z-index: 90; inset: 0; display: flex; align-items: center; justify-content: center; padding: 28rpx; background: rgba(16, 55, 43, .66); backdrop-filter: blur(5px); }
.eg-weekly-card { width: 100%; max-width: 664rpx; max-height: calc(100vh - 56rpx); overflow-y: auto; padding: 28rpx 26rpx 22rpx; border: 5rpx solid rgba(255,255,255,.96); border-radius: 38rpx; background: linear-gradient(160deg, #f5fff9 0%, #fffdf3 56%, #fff4db 100%); box-shadow: 0 30rpx 80rpx rgba(5,45,32,.38); animation: egWeeklyIn .36s cubic-bezier(.18,.88,.27,1.08) both; }
.eg-weekly-head { display: flex; align-items: center; justify-content: space-between; }
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
.eg-weekly-progress-label { display: block; color: #657b73; font-size: 22rpx; font-weight: 700; }
.eg-weekly-progress-value { display: block; margin-top: -2rpx; color: #e06b24; font-size: 43rpx; font-weight: 1000; }
.eg-weekly-progress-copy { display: flex; flex-direction: column; align-items: flex-end; color: #235b49; font-size: 24rpx; font-weight: 900; line-height: 1.55; }
.eg-weekly-progress-copy text:last-child { color: #b86820; }
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
.eg-weekly-row.is-waiting { color: #8a9b94; border-style: dashed; border-color: #dbe8e2; background: rgba(247,251,249,.72); }
.eg-weekly-row.is-waiting .eg-weekly-avatar { color: #6e887e; border-color: #dce9e3; background: #edf4f1; box-shadow: none; }
.eg-weekly-row.is-waiting .eg-weekly-count { color: #9baba4; }
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
.eg-weekly-avatar.is-tone-5 { background: #a673bd; }
.eg-weekly-avatar.is-tone-6 { background: #448e9c; }
.eg-weekly-avatar.is-tone-7 { background: #c27b49; }
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
.eg-weekly-connection { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; margin-top: 13rpx; padding: 12rpx 15rpx; border: 2rpx solid #e4d7b2; border-radius: 18rpx; color: #6b6655; background: #fff9e8; font-size: 21rpx; font-weight: 700; }
.eg-weekly-connection > text:first-child { flex: 1; }
.eg-weekly-retry { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 999rpx; color: #fff; background: #23765c; font-weight: 900; }
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
.eg-cheer-top { display: flex; align-items: center; justify-content: space-between; }
.eg-cheer-source { color: #8e5c18; font-size: 21rpx; font-weight: 900; }
.eg-cheer-card.is-family .eg-cheer-source { color: #77539c; }
.eg-cheer-change { padding: 5rpx 12rpx; border-radius: 999rpx; color: #176b52; background: rgba(255,255,255,.75); font-size: 19rpx; font-weight: 900; }
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-weekly-share::after { border: 0; }
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
.eg-stage-card { width: 100%; max-width: 620rpx; padding: 34rpx 28rpx 30rpx; border: 6rpx solid #fff; border-radius: 38rpx; background: linear-gradient(155deg, #f5fff9, #fff8da); box-shadow: 0 26rpx 70rpx rgba(10,56,41,.3); text-align: center; animation: egStageIn 1.8s cubic-bezier(.16,.86,.28,1) both; }
.eg-stage-kicker { display: block; color: #16815f; font-size: 23rpx; font-weight: 900; letter-spacing: 3rpx; }
.eg-stage-number { display: block; margin-top: 5rpx; color: #e47722; font-size: 48rpx; font-weight: 1000; line-height: 1.1; }
.eg-stage-title { display: block; margin-top: 7rpx; color: #194e3d; font-size: 38rpx; font-weight: 900; }
.eg-stage-foods { display: flex; justify-content: center; gap: 9rpx; margin-top: 22rpx; }
.eg-stage-food { display: flex; width: 92rpx; min-height: 110rpx; flex-direction: column; align-items: center; justify-content: center; border: 3rpx solid #dbece4; border-radius: 20rpx; color: #315b4e; background: #fff; font-size: 20rpx; font-weight: 800; }
.eg-stage-food > image { width: 72rpx; height: 52rpx; border-radius: 12rpx; }
.eg-stage-food > text { margin-top: 8rpx; }
.eg-stage-food.is-danger { border-color: #ef8b52; color: #a43c20; background: #fff1e6; box-shadow: 0 0 0 4rpx rgba(239,139,82,.12); }
.eg-stage-tip { display: block; margin-top: 20rpx; color: #6e776d; font-size: 21rpx; font-weight: 700; }
.eg-reshuffle-overlay { position: fixed; z-index: 65; inset: 0; display: flex; align-items: center; justify-content: center; padding: 46rpx; background: rgba(19,55,45,.5); }
.eg-reshuffle-card { width: 100%; max-width: 540rpx; padding: 38rpx 30rpx; border: 5rpx solid rgba(255,255,255,.95); border-radius: 36rpx; background: linear-gradient(155deg, #f4fff9, #e6f6ef); box-shadow: 0 24rpx 64rpx rgba(10,48,37,.3); text-align: center; }
.eg-reshuffle-icon { display: flex; width: 94rpx; height: 94rpx; align-items: center; justify-content: center; margin: 0 auto 18rpx; border-radius: 50%; background: linear-gradient(135deg, #2aa87b, #126a50); box-shadow: 0 10rpx 24rpx rgba(18,106,80,.28); animation: egReshuffleSpin .85s linear infinite; }
.eg-reshuffle-title { display: block; color: #174e3d; font-size: 36rpx; font-weight: 900; }
.eg-reshuffle-desc { display: block; margin-top: 10rpx; color: #5b746b; font-size: 24rpx; font-weight: 700; }
.eg-overlay { position: fixed; z-index: 50; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; padding: 36rpx; background: rgba(20, 48, 40, .62); }
.eg-modal { width: 100%; max-width: 650rpx; padding: 34rpx 28rpx 28rpx; border-radius: 38rpx; background: #fffdf8; box-shadow: 0 28rpx 70rpx rgba(0,0,0,.22); }
.eg-modal-icon { display: block; font-size: 68rpx; text-align: center; }
.eg-modal-title { display: block; margin-top: 6rpx; color: #204e3d; font-size: 38rpx; font-weight: 900; text-align: center; }
.eg-modal-sub { display: block; margin: 8rpx 0 22rpx; color: #718078; font-size: 24rpx; text-align: center; }
.eg-result-warning { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; margin: 0 auto; border-radius: 50%; color: #fff; background: #e4572e; font-size: 60rpx; font-weight: 900; }
.eg-result-food { display: block; margin: 12rpx 0 18rpx; color: #c34b27; font-size: 28rpx; font-weight: 800; text-align: center; }
.eg-knowledge-card { padding: 22rpx; border-radius: 24rpx; background: #fff3e7; }
.eg-knowledge-title { display: block; color: #8d4427; font-size: 26rpx; font-weight: 800; }
.eg-knowledge-copy { display: block; margin-top: 9rpx; color: #5f514a; font-size: 23rpx; line-height: 1.65; }
.eg-knowledge-review { display: block; margin-top: 12rpx; color: #9b7768; font-size: 19rpx; }
.eg-result-score { display: flex; align-items: center; justify-content: space-between; margin: 20rpx 6rpx; color: #466258; font-size: 25rpx; }
.eg-result-score text:last-child { color: #e16f24; font-size: 36rpx; font-weight: 900; }
.eg-result-best { display: flex; align-items: center; justify-content: space-between; margin: -12rpx 6rpx 20rpx; color: #75867f; font-size: 22rpx; }
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-share-btn::after { border: 0; }
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
@keyframes egPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
50% { box-shadow: 0 0 0 8rpx rgba(245,158,11,.12); }
}
@keyframes egWeeklyIn {
0% { opacity: 0; transform: translateY(34rpx) scale(.91); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes egHit {
0% { opacity: 1; transform: scale(1); }
42% { opacity: 1; transform: scale(1.18) rotate(-3deg); }
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
}
@keyframes egVillainPeek {
0%, 72%, 100% { transform: translateY(0) rotate(0); filter: saturate(1.08) contrast(1.05); }
80% { transform: translateY(-3rpx) rotate(-1.5deg); filter: saturate(1.15) contrast(1.08); }
88% { transform: translateY(0) rotate(1.5deg); }
95% { transform: translateY(-2rpx) rotate(0); }
}
@keyframes egTaskRewardFlight {
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
72% { opacity: 1; transform: translate3d(270rpx, 900rpx, 0) scale(.68) rotate(8deg); }
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
}
@keyframes egNavNotice {
0% { opacity: 0; transform: translate(-50%, -42%); }
12% { opacity: .9; transform: translate(-50%, -50%); }
42% { opacity: .82; transform: translate(-50%, -50%); }
100% { opacity: 0; transform: translate(-50%, -58%); }
}
@keyframes egRewardGlow {
from { opacity: .4; transform: scale(.86); }
to { opacity: .9; transform: scale(1.12); }
}
@keyframes egFireSweep {
0% { opacity: 0; transform: translate3d(-38%, 20rpx, 0) scaleX(.6); }
28% { opacity: 1; }
72% { opacity: .96; }
100% { opacity: 0; transform: translate3d(58%, -15rpx, 0) scaleX(1.08); }
}
@keyframes egFireSpark {
0% { opacity: 0; transform: translateY(14rpx) scale(.5) rotate(35deg); }
38% { opacity: 1; }
100% { opacity: 0; transform: translateY(-38rpx) scale(1.05) rotate(58deg); }
}
@keyframes egFireGlow {
0% { opacity: 0; transform: scale(.72); }
38% { opacity: 1; transform: scale(1.08); }
100% { opacity: 0; transform: scale(1.28); }
}
@keyframes egFireCellHit {
0% { opacity: 1; transform: scale(1); }
24% { opacity: 1; transform: scale(1.09); }
72% { opacity: 1; transform: scale(1.02); }
100% { opacity: 0; transform: scale(.48); }
}
@keyframes egFireFlame {
0% { opacity: 0; transform: translateY(20rpx) rotate(42deg) scale(.55); }
38% { opacity: 1; transform: translateY(0) rotate(42deg) scale(1.05); }
100% { opacity: 0; transform: translateY(-31rpx) rotate(48deg) scale(.86); }
}
@keyframes egFlameReady {
from { transform: scale(.94); filter: brightness(.96); }
to { transform: scale(1.08); filter: brightness(1.18); }
}
@keyframes egLReady {
from { transform: scale(.94) rotate(-2deg); filter: brightness(.96); }
to { transform: scale(1.06) rotate(2deg); filter: brightness(1.12); }
}
@keyframes egImpact {
0% { opacity: 0; transform: translate(-50%, -42%) scale(.55); }
35% { opacity: 1; transform: translate(-50%, -50%) scale(1.16); }
72% { opacity: 1; transform: translate(-50%, -54%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -76%) scale(.92); }
}
@keyframes egJackpotIn {
0% { opacity: 0; transform: translate(-50%, -45%) scale(.48) rotate(-4deg); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.09) rotate(2deg); }
31% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
78% { opacity: 1; transform: translate(-50%, -53%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -68%) scale(.92); }
}
@keyframes egTripleIn {
0% { opacity: 0; transform: translate(-50%, -44%) scale(.82); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
74% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -60%) scale(.96); }
}
@keyframes egTripleCan {
0% { opacity: 0; transform: translateY(18rpx) scale(.72); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes egFourIn {
0% { opacity: 0; transform: translate(-50%, -44%) scale(.62) rotate(-3deg); }
18% { opacity: 1; transform: translate(-50%, -50%) scale(1.08) rotate(1deg); }
29% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
78% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -65%) scale(.94); }
}
@keyframes egFourCan {
0% { opacity: 0; transform: translateY(-22rpx) rotate(-7deg) scale(.65); }
75% { opacity: 1; transform: translateY(5rpx) rotate(2deg) scale(1.05); }
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
}
@keyframes egFiveCan {
0% { opacity: 0; transform: translateY(-40rpx) scale(.52) rotate(-8deg); filter: brightness(1.5); }
70% { opacity: 1; transform: translateY(7rpx) scale(1.08) rotate(2deg); }
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0); filter: brightness(1); }
}
@keyframes egLockedCan {
from { opacity: .62; transform: scale(.92); filter: brightness(.9); }
to { opacity: 1; transform: scale(1.06); filter: brightness(1.2); }
}
@keyframes egPromisePulse {
from { transform: scale(.97); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); }
to { transform: scale(1.03); box-shadow: 0 7rpx 21rpx rgba(255,221,89,.38); }
}
@keyframes egReelStop {
0% { opacity: .2; transform: translateY(-48rpx) scaleY(1.4); filter: blur(5rpx); }
70% { opacity: 1; transform: translateY(7rpx) scaleY(.94); filter: blur(0); }
100% { transform: translateY(0) scaleY(1); }
}
@keyframes egLights {
0% { opacity: .35; }
100% { opacity: 1; }
}
@keyframes egConfettiFall {
0% { opacity: 0; transform: translate3d(0, -8vh, 0) rotate(0deg) scale(.7); }
9% { opacity: 1; }
48% { transform: translate3d(42rpx, 48vh, 0) rotate(420deg) scale(1); }
100% { opacity: .92; transform: translate3d(-28rpx, 112vh, 0) rotate(920deg) scale(.86); }
}
@keyframes egPointsPop {
0% { opacity: 0; transform: scale(.35) rotate(-7deg); }
55% { opacity: 1; transform: scale(1.28) rotate(3deg); }
78% { transform: scale(.92) rotate(0); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes egFlowShell {
from { filter: saturate(1); }
to { filter: saturate(1.08); }
}
@keyframes egFlowBadge {
from { transform: scale(.98); filter: brightness(.96); }
to { transform: scale(1.035); filter: brightness(1.12); }
}
@keyframes egStageIn {
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
18% { opacity: 1; transform: scale(1.06) translateY(0); }
30%, 76% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.96) translateY(-22rpx); }
}
@keyframes egReshuffleSpin {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
@media screen and (max-height: 760px) {
.eg-task-card { padding-top: 13rpx; padding-bottom: 13rpx; }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30 -10
View File
@@ -419,7 +419,7 @@
<view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }"> <view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }">
<view class="ai-diet-section-head"> <view class="ai-diet-section-head">
<text class="ai-diet-section-title">今日三餐</text> <text class="ai-diet-section-title">今日饮食</text>
<text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中</text> <text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中</text>
</view> </view>
<view class="ai-diet-meal-list"> <view class="ai-diet-meal-list">
@@ -434,6 +434,20 @@
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor"></text> {{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor"></text>
</text> </text>
</view> </view>
<view
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
class="ai-diet-meal-card meal-drinks"
>
<view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true">
<TongjiIcon name="droplet" size="sm" color="#ffffff" />
</view>
<text class="ai-diet-meal-label">喝的</text>
</view>
<text class="ai-diet-meal-text">
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="ai-diet-meal-card meal-lunch"> <view class="ai-diet-meal-card meal-lunch">
<view class="ai-diet-meal-head"> <view class="ai-diet-meal-head">
<view class="ai-diet-meal-icon" aria-hidden="true"> <view class="ai-diet-meal-icon" aria-hidden="true">
@@ -2657,16 +2671,13 @@ async function bootstrap(options) {
if (!target) target = list[0] if (!target) target = list[0]
await applyCard(target) await applyCard(target)
} else { } else {
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId diagnosisId.value = 0
if (passedPatientId) patientId.value = passedPatientId patientId.value = 0
if (passedPatientName) patientName.value = passedPatientName patientName.value = ''
if (passedAge) age.value = passedAge age.value = 0
if (passedGender) gender.value = passedGender gender.value = 0
if (!diagnosisId.value || !patientId.value) {
readUserContext()
}
readUserMobile() readUserMobile()
if (!diagnosisId.value && userMobile.value) { if (userMobile.value) {
await ensureDailyContextByPhone() await ensureDailyContextByPhone()
} }
} }
@@ -6558,6 +6569,12 @@ async function onFamilyLike() {
.ai-diet-meal-card.meal-breakfast::before { .ai-diet-meal-card.meal-breakfast::before {
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%); background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
} }
.ai-diet-meal-card.meal-drinks {
background: linear-gradient(120deg, rgba(14, 165, 233, 0.14) 0%, rgba(255, 255, 255, 0) 45%);
}
.ai-diet-meal-card.meal-drinks::before {
background: linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
}
.ai-diet-meal-card.meal-lunch { .ai-diet-meal-card.meal-lunch {
background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%); background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%);
} }
@@ -6588,6 +6605,9 @@ async function onFamilyLike() {
.meal-breakfast .ai-diet-meal-icon { .meal-breakfast .ai-diet-meal-icon {
background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%); background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%);
} }
.meal-drinks .ai-diet-meal-icon {
background: linear-gradient(150deg, #7dd3fc 0%, #0ea5e9 100%);
}
.meal-lunch .ai-diet-meal-icon { .meal-lunch .ai-diet-meal-icon {
background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%); background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%);
} }
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More