Compare commits

...
61 Commits
Author SHA1 Message Date
Your Name 15e9c486f9 更新 2026-06-02 18:33:25 +08:00
Your Name 62f74888f3 Merge branch 'master' into master-6-2 2026-06-02 16:49:42 +08:00
Your Name 79719b801e 更新 2026-06-02 16:47:50 +08:00
long 07dacbcb28 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-06-02 14:20:26 +08:00
long d364fe506a 1 2026-06-02 14:20:20 +08:00
Your Name 860b550180 更新 2026-06-02 14:15:49 +08:00
Your Name 707e4888ad 更新bug 2026-06-02 10:16:03 +08:00
Your Name b0a8bd83bc 更新 2026-06-01 18:39:09 +08:00
Your Name 574d37908a Merge branch 'master' into master-6-1 2026-06-01 18:36:15 +08:00
Your Name 62512d30f9 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-06-01 18:35:28 +08:00
Your Name be15531ab3 更新 2026-06-01 18:34:50 +08:00
long 1dd5133202 Merge branch '20260601-long' 2026-06-01 18:30:37 +08:00
long 51b34a98b5 Update list.vue 2026-06-01 18:30:29 +08:00
Your Name 1755185f50 Merge branch 'master' into master-5-28 2026-06-01 18:17:46 +08:00
Your Name b3d3048100 更新 2026-06-01 18:11:05 +08:00
Your Name a38af5bb13 更新 2026-06-01 18:04:23 +08:00
Your Name 0a781bda54 更新 2026-06-01 17:43:01 +08:00
long 450be5852f Merge branch '20260601-long' 2026-06-01 17:02:47 +08:00
long 3c35ab67cd update
h5 :问诊列表
add 完成诊单  +医生备注
2026-06-01 17:00:38 +08:00
Your Name cbf992d6df gengxin 2026-06-01 16:40:05 +08:00
Your Name 7aec1a9713 更新 2026-06-01 16:30:25 +08:00
Your Name 01bc355bed 更新 2026-06-01 10:01:59 +08:00
Your Name 1b746b46c0 更新 2026-06-01 09:59:32 +08:00
Your Name ceb313b1a7 更新 2026-06-01 09:38:44 +08:00
Your Name 7cfa4d269a 更新 2026-06-01 09:34:27 +08:00
long 576ab05681 Merge branch 'mini-sport' 2026-05-29 18:17:58 +08:00
long 132a17b946 update 2026-05-29 18:17:44 +08:00
Your Name 75dfaa0dcd Merge branch 'master' into master-5-28 2026-05-29 17:11:08 +08:00
Your Name e83dd07553 更新 2026-05-29 17:08:57 +08:00
long c589bfa647 1 2026-05-29 15:59:14 +08:00
Your Name 17ad153821 更新 2026-05-29 14:45:56 +08:00
long e4312ab5d1 update 2026-05-29 12:12:07 +08:00
long 7f4fad7b62 update 2026-05-29 12:05:22 +08:00
longandCursor 9b5cd5b2b9 优化握力环训练页:内联视频舞台并修复小程序兼容与完成浮层布局。
将示范视频从自定义组件移至页面主体,避免 mp 端 vOn/ref 崩溃;奖励特效改为 prop 信号驱动,完成弹层移出 page 容器修复右侧裁切,并修正后台音频配置与 CLI 热重载监听。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 10:59:17 +08:00
Your Name 94e787ae80 更新 2026-05-29 09:28:38 +08:00
Your Name 7b46204454 更新 2026-05-29 09:28:27 +08:00
long 9f3db8f281 update 2026-05-28 17:44:30 +08:00
long 6cb8688587 Merge branch 'feat/vite-build' 2026-05-28 15:28:16 +08:00
long 0e5b5beb9c 增加 cli 启动命令
npm run dev:mp-weixin
2026-05-28 15:27:45 +08:00
long 8d635bb7ed update 2026-05-28 15:10:06 +08:00
Your Name 0f974be7a1 Merge branch 'master-5-27' into master-5-28 2026-05-28 14:46:50 +08:00
Your Name 2b1ce61e72 更新 2026-05-28 14:41:50 +08:00
long 6efc340bd8 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-28 14:33:38 +08:00
long c435e6ae4e Update main.js 2026-05-28 14:33:31 +08:00
Your Name dfb8838ecc 发布按 2026-05-28 12:21:53 +08:00
Your Name 7f08771dd0 更新 2026-05-28 12:18:14 +08:00
long afd88714ec Merge branch 'mini-sport' 2026-05-28 11:49:41 +08:00
long bb21825c16 ADD total_open_rate
增加开口率
2026-05-28 11:49:23 +08:00
longandClaude Opus 4.7 c3e4dfa479 feat(training): 握力环训练全面优化 - 音效集成 + 沉浸式画布 + Canvas 2D
## 主要改动

### 1. 音效集成
- 集成 4 个捏碎音效(鸡蛋/核桃/易拉罐/气球)
- 集成背景音乐 BGM + 右上角开关按钮
- 修复音效 URL 空值时的崩溃问题
- useMetronome 新增 volume 参数(默认 1.0)
- 节拍音量调小至 0.35,避免木鱼声过大

### 2. 画布升级到 Canvas 2D 接口
- 解决微信小程序 canvas-id 接口的警告
- 支持同层渲染,可与其他元素正常叠加
- 使用 requestAnimationFrame 替代 setTimeout
- DPR 自适应,Retina 屏幕高清渲染

### 3. 粒子特效差异化升级
- 4 种物品各自独立配色和形状
  - 🥚 鸡蛋: 蛋黄黄+白色,圆形,中速
  - 🌰 核桃: 4 种棕色,方形/三角/长方形,重粒
  - 🥫 易拉罐: 5 种银色,长方形,快速
  - 🎈 气球: 8 色彩虹,小颗粒,飘浮(低重力)
- 新增双层冲击波效果
- 粒子旋转动画(方形/三角形/长方形)
- 多形状粒子绘制(circle/square/triangle/rect)

### 4. 沉浸式画布重新设计
- 画布占屏幕 60vh(min 720rpx, max 900rpx)
- 背景与页面无缝融合(#f8fafc)
- 中心 1400rpx 大放射光晕,营造空间感
- 握力环放大至 520rpx,内圈 280rpx
- 完全移除浮动气泡(性能优化)

### 5. 视觉与体验优化
- 主题色改回青绿色(#14b8a6),握力环保持粉色突出
- 训练完成卡片大改造:
  - 大号 emoji 跳动动画
  - 统计项加入图标(💪⏱️🔥)
  - 食物对照可视化(emoji + 数量,最多 3 个)
  - 食物库扩展到 7 种(糖果/饼干/巧克力/苹果/香蕉/米饭/可乐)
- BPM 数字大号显示(80rpx 深粉色)
- 实时统计栏增加分隔线和顶部彩色高光

### 6. 性能优化(低配机器友好)
- 静态背景渐变(0 性能消耗)
- 单层中心光晕(无动画)
- 外发光仅在播放时启用
- GPU 加速(will-change + transform/opacity)
- 移除 backdrop-filter(高消耗)

### 7. Bug 修复
- crush-canvas 的 \$scope of null 错误
- 进入页面时音效预热不再发声(静音预热)
- 修复 walnut emoji 错用花生(🥜🌰)
- 弹窗遮挡粒子动画(延迟 1200ms 显示弹窗)

### 8. 文档
- 新增音效采集清单(grip-ring-audio-shopping-list.md)

## 修改文件
- grip-ring.vue: 主页面,音效集成、UI 重设计、食物对照
- grip-ring-canvas.vue: 沉浸式画布、握力环主体
- crush-canvas.vue: Canvas 2D 升级、差异化粒子系统
- useMetronome.ts: 音量参数支持
- dev-training-entry/index.vue: 入口配置微调

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:35:43 +08:00
Your Name 33f8f669ad 更新 2026-05-28 10:50:21 +08:00
long 3ebe3c58c8 Update metronome.vue 2026-05-27 18:27:25 +08:00
longandClaude Opus 4.7 0225e9f971 feat(training): 握力环页面简化重构
## 主要改动

### 1. 连续训练模式
- 移除组数/休息逻辑,只统计总次数和时长
- 训练持续进行直到用户手动暂停
- 实时显示次数、时长、消耗糖分

### 2. 递进式奖励系统
- 鸡蛋(8-12次) → 核桃(15-20次) → 易拉罐(25-30次) → 气球(40-50次)
- 每个奖励在区间内随机触发
- 奖励按顺序递进,不会跳级
- 捏碎粒子特效 + 奖励弹窗

### 3. 糖分计算与食物对比
- 公式: (MET × 体重 × 时长) / 4
- MET = 3.5, 体重 = 60kg
- 食物对比: 苹果/香蕉/米饭/巧克力/可乐

### 4. 视觉设计优化
- 真实握力环造型(完整圆环 + 中空 + 握点)
- 立体渐变和纹理效果
- 挤压动画(训练时整体缩放)
- 简洁交互(整个环可点击,无中心按钮)

### 5. 问题修复
- 修复进入页面时音效预热响声(静音预热)
- 暂时禁用音效URL(避免404错误)
- 修复捏碎特效层级问题

## 技术实现

- 新增: grip-ring.vue (主页面)
- 新增: grip-ring-canvas.vue (画布组件)
- 修改: useMetronome.ts (静音预热)
- 修改: exercises.ts (移除握力环)
- 修改: index.vue (添加头部链接)
- 修改: dev-training-entry/index.vue (添加菜单入口)
- 修改: pages.json (添加路由)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:26:45 +08:00
Your Name 4f066b560e gengx 2026-05-27 18:11:26 +08:00
Your Name 13f58a5fdc 更新 2026-05-27 17:44:50 +08:00
Your Name 36be8fedad 更新 2026-05-27 17:29:27 +08:00
long b9d2541b32 chore: record session journal - 握力环训练趣味化 2026-05-27 16:53:33 +08:00
longandClaude Opus 4.7 261f8315de feat(training): 握力环训练趣味化 - 捏碎特效 + 卡路里统计
## 新增功能

### 1. Canvas 粒子捏碎特效
- 新建 crush-canvas.vue 组件(透明叠加层)
- 4 种捏碎物品:鸡蛋🥚、核桃🥜、易拉罐🥫、气球🎈
- 粒子物理模拟:径向扩散 + 重力 + 空气阻力 + 生命值衰减
- 性能优化:固定 60fps 时间步长,粒子边界清理

### 2. 随机触发逻辑
- 平均每 8-12 次随机触发捏碎特效
- 在握力环压缩峰值(scale 0.65)时触发
- 随机选择 4 种物品之一
- 资源清理:训练暂停/结束时清理所有定时器

### 3. 卡路里统计与完成页面
- 标准 MET 公式计算卡路里(MET = 3.5,默认 60kg)
- 5 种食物自动匹配(苹果、鸡蛋、巧克力、香蕉、米饭)
- 完成页面显示:组数、次数、时长、卡路里、食物对比
- 样式设计:浅色清爽风格,绿色渐变卡片

### 4. 预留接口
- useTTS.ts:预留 MiMo TTS 语音鼓励接口

## 技术实现

- Canvas 粒子系统:20-30 个粒子,物理模拟流畅
- 类型安全:完整的 TypeScript 类型定义
- 性能优化:固定时间步长、边界清理、资源管理
- 响应式布局:适配不同屏幕尺寸

## 相关任务

Task: .trellis/tasks/05-27-grip-ring-gamification
PRD: prd.md
Research: research/calorie-calculation.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:52:41 +08:00
long ffe4849c27 Merge branch 'master' into mini-sport 2026-05-27 16:34:13 +08:00
Your Name 19af50c344 更新 2026-05-27 15:50:54 +08:00
long 012830de4a 1 2026-05-27 14:47:29 +08:00
Your Name 8b0fcd7050 更新 2026-05-26 18:15:56 +08:00
439 changed files with 54021 additions and 6172 deletions
+1 -3
View File
@@ -1,5 +1,3 @@
{
"kiroAgent.configureMCP": "Disabled"
"kiroAgent.configureMCP": "Enabled"
}
+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
.DS_Store
dist
unpackage/dist
*.local
# Editor directories and files
-2
View File
@@ -31,8 +31,6 @@ onLaunch(() => {
uni.login({
provider: 'weixin',
success: function (loginRes) {
console.log('xx');
console.log(loginRes.code);
if( uni.getStorageSync('token')){
userinfo(loginRes.code);
}else{
+24
View File
@@ -1,6 +1,30 @@
## 简介
本 demo 演示了如何在 uni-app 项目中集成 [TUICallKit](https://www.npmjs.com/package/@trtc/calls-uikit-wx-uniapp) 音视频通话组件。
## 两种开发方式
本工程同时支持 **HBuilderX****命令行 (vite)** 两套编译链路,二选一即可。
### 方式 A:命令行 (vite)
```bash
npm install # 安装依赖
npm run dev:mp-weixin # watch 模式,产物 dist/dev/mp-weixin
npm run build:mp-weixin # 生产构建,产物 dist/build/mp-weixin
```
构建完成后用微信开发者工具打开对应 `dist/.../mp-weixin` 目录即可。
支持平台:`mp-weixin` / `h5` / `app`(脚本里有 dev/build 两组)。
### 方式 BHBuilderX
HBuilderX 3.4+ 会自动识别 `vite.config.ts` 并切换到 vite 编译模式。
1. 用 HBuilderX 打开工程根目录
2. 菜单「运行」→「运行到小程序模拟器」→「微信开发者工具」
3. 产物会输出到 `unpackage/dist/dev/mp-weixin`
## 环境准备
@@ -0,0 +1,137 @@
<template>
<view class="tab-dock">
<view class="tab-dock__inner">
<view class="tab-dock__item" @click="goHome">
<image
class="tab-dock__icon"
:src="active === 0 ? '/static/user/home_no.png' : '/static/user/home.png'"
mode="aspectFit"
/>
<text class="tab-dock__label" :style="{ color: active === 0 ? activeColor : color }">首页</text>
</view>
<view class="tab-dock__item tab-dock__item--center" @click="goWeekly">
<view class="tab-dock__fab" :class="{ 'tab-dock__fab--active': active === 1 }">
<text class="tab-dock__fab-text"></text>
</view>
<text class="tab-dock__label tab-dock__label--center" :style="{ color: active === 1 ? activeColor : color }">血糖</text>
</view>
<view class="tab-dock__item" @click="goUser">
<image
class="tab-dock__icon"
:src="active === 2 ? '/static/user/user_no.png' : '/static/user/user.png'"
mode="aspectFit"
/>
<text class="tab-dock__label" :style="{ color: active === 2 ? activeColor : color }">我的</text>
</view>
</view>
<view class="tab-dock__safe" />
</view>
</template>
<script setup>
const props = defineProps({
/** 0 首页 1 血糖 2 我的 */
active: {
type: Number,
default: 0
}
})
const color = '#8a8a8a'
const activeColor = '#204e2b'
function goHome() {
if (props.active === 0) return
uni.redirectTo({ url: '/pages/index/index' })
}
function goUser() {
if (props.active === 2) return
uni.redirectTo({ url: '/pages/user/user' })
}
function goWeekly() {
if (props.active === 1) return
uni.redirectTo({ url: '/tongji/pages/weekly' })
}
</script>
<style scoped lang="scss">
.tab-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: #ffffff;
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -6rpx 20rpx rgba(0, 0, 0, 0.04);
}
.tab-dock__inner {
display: flex;
align-items: flex-end;
height: 100rpx;
}
.tab-dock__item {
flex: 1;
height: 100rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tab-dock__item--center {
justify-content: flex-end;
padding-bottom: 6rpx;
}
.tab-dock__icon {
width: 46rpx;
height: 46rpx;
}
.tab-dock__label {
margin-top: 6rpx;
font-size: 22rpx;
line-height: 1.2;
}
.tab-dock__label--center {
margin-top: 8rpx;
}
.tab-dock__fab {
width: 96rpx;
height: 96rpx;
margin-top: -48rpx;
border-radius: 50%;
background: linear-gradient(180deg, #2d7340 0%, #204e2b 100%);
border: 6rpx solid #ffffff;
box-shadow: 0 10rpx 24rpx rgba(32, 78, 43, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.tab-dock__fab--active {
transform: scale(1.04);
box-shadow: 0 12rpx 28rpx rgba(32, 78, 43, 0.42);
}
.tab-dock__fab-text {
color: #ffffff;
font-size: 38rpx;
font-weight: 700;
line-height: 1;
}
.tab-dock__safe {
height: env(safe-area-inset-bottom);
background: #ffffff;
}
</style>
@@ -1,19 +1,98 @@
<template>
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 页面内嵌模块 weekly 统计页主内容区 -->
<view v-if="visible && props.inline" class="dev-entry-inline">
<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
v-for="item in trainingItems"
:key="item.url"
class="inline-item"
@click="goto(item.url)"
>
<view class="menu-icon" :class="item.iconClass">
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" />
<view class="dumbbell-bar" />
<view class="dumbbell-plate dumbbell-plate-right" />
</view>
<view v-else-if="item.icon === 'pedal'" class="foot-pedal-shape">
<view class="pedal-bar pedal-bar-left" />
<view class="pedal-bar pedal-bar-right" />
</view>
<view v-else-if="item.icon === 'pilates'" class="pilates-ring-shape" />
<view v-else-if="item.icon === 'grip'" class="grip-ring-shape" />
<view v-else-if="item.icon === 'metro'" class="menu-icon--metro-inner">
<view class="metro-bar metro-bar-1" />
<view class="metro-bar metro-bar-2" />
<view class="metro-bar metro-bar-3" />
</view>
</view>
<text class="inline-item-title">{{ item.title }}</text>
<text class="inline-item-sub">{{ item.sub }}</text>
</view>
</view>
</view>
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 展开后的菜单卡片(从下往上动画) -->
<view v-if="expanded" class="menu-list" @click.stop>
<!-- "练一练" 暂时隐藏,后续恢复时取消注释即可
<view class="menu-item" @click="goto('/training/pages/index')">
<view class="menu-icon menu-icon&#45;&#45;train">
<text class="menu-icon-text">💪</text>
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
<view class="menu-icon menu-icon--dumbbell">
<view class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" />
<view class="dumbbell-bar" />
<view class="dumbbell-plate dumbbell-plate-right" />
</view>
</view>
<view class="menu-info">
<text class="menu-title">练一练</text>
<text class="menu-sub">器械跟练</text>
<text class="menu-title">哑铃</text>
<text class="menu-sub">力量塑形</text>
</view>
<view class="menu-arrow"></view>
</view>
-->
<view class="menu-item" @click="goto('/training/pages/foot-pedal')">
<view class="menu-icon menu-icon--pedal">
<view class="foot-pedal-shape">
<view class="pedal-bar pedal-bar-left" />
<view class="pedal-bar pedal-bar-right" />
</view>
</view>
<view class="menu-info">
<text class="menu-title">脚蹬器</text>
<text class="menu-sub">下肢有氧</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/pilates-ring')">
<view class="menu-icon menu-icon--pilates">
<view class="pilates-ring-shape" />
</view>
<view class="menu-info">
<text class="menu-title">瑜伽环</text>
<text class="menu-sub">核心塑形</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/grip-ring')">
<view class="menu-icon menu-icon--grip">
<view class="grip-ring-shape" />
</view>
<view class="menu-info">
<text class="menu-title">握力环</text>
<text class="menu-sub">握力训练</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/metronome')">
<view class="menu-icon menu-icon--metro">
<view class="metro-bar metro-bar-1" />
@@ -21,7 +100,7 @@
<view class="metro-bar metro-bar-3" />
</view>
<view class="menu-info">
<text class="menu-title">节拍器</text>
<text class="menu-title">耗糖节拍器</text>
<text class="menu-sub">健走配速</text>
</view>
<view class="menu-arrow"></view>
@@ -47,11 +126,22 @@
<script setup lang="ts">
import { ref } from 'vue'
const trainingItems = [
{ url: '/training/pages/dumbbell', title: '哑铃', sub: '力量塑形', icon: 'dumbbell', iconClass: 'menu-icon--dumbbell' },
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro' },
] as const
const props = withDefaults(
defineProps<{
/** 悬浮按钮距底部 rpx(仅非 inline */
bottom?: number
/** 内嵌到页面主内容区,非 fixed 悬浮 */
inline?: boolean
}>(),
{ bottom: 200 },
{ bottom: 200, inline: false },
)
const isDevMode = (): boolean => {
@@ -251,6 +341,22 @@ $brand-light: #34d399;
&--train {
background: linear-gradient(140deg, $brand-light, $brand-deep);
}
&--dumbbell {
background: linear-gradient(140deg, #93c5fd, #3b82f6);
box-shadow: 0 2rpx 8rpx rgba(59, 130, 246, 0.36);
}
&--pedal {
background: linear-gradient(140deg, #fdba74, #f97316);
box-shadow: 0 2rpx 8rpx rgba(249, 115, 22, 0.36);
}
&--pilates {
background: linear-gradient(140deg, #c4b5fd, #8b5cf6);
box-shadow: 0 2rpx 8rpx rgba(139, 92, 246, 0.36);
}
&--grip {
background: linear-gradient(140deg, #5eead4, #14b8a6);
box-shadow: 0 2rpx 8rpx rgba(20, 184, 166, 0.36);
}
&--metro {
background: linear-gradient(140deg, #fbbf24, #d97706);
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
@@ -263,6 +369,94 @@ $brand-light: #34d399;
line-height: 1;
}
/* 哑铃小 icon */
.dumbbell-shape {
width: 40rpx;
height: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.dumbbell-plate {
width: 10rpx;
height: 18rpx;
background: #fff;
border-radius: 3rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.dumbbell-bar {
width: 16rpx;
height: 5rpx;
background: #fff;
border-radius: 2rpx;
margin: 0 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
/* 脚蹬器小 icon: 左右踏板 */
.foot-pedal-shape {
width: 40rpx;
height: 28rpx;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 6rpx;
}
.pedal-bar {
width: 14rpx;
background: #fff;
border-radius: 4rpx 4rpx 2rpx 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.pedal-bar-left {
height: 18rpx;
transform: rotate(-12deg);
}
.pedal-bar-right {
height: 22rpx;
transform: rotate(12deg);
}
/* 瑜伽环小 icon: 双弧阻力环 */
.pilates-ring-shape {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 5rpx solid #fff;
position: relative;
box-shadow:
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 18rpx;
height: 18rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 3rpx solid rgba(255, 255, 255, 0.85);
}
}
/* 握力环小 icon: 白色描边的环(甜甜圈) */
.grip-ring-shape {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 7rpx solid #fff;
box-shadow:
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
}
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
.metro-bar {
width: 4rpx;
@@ -306,4 +500,121 @@ $brand-light: #34d399;
line-height: 1;
margin-left: 4rpx;
}
.menu-icon--metro-inner {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4rpx;
width: 100%;
height: 100%;
}
/* ============================================================
* 内嵌模块(weekly 等)
* ============================================================ */
.dev-entry-inline {
width: 100%;
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;
}
.inline-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 20rpx;
}
.inline-head-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.inline-title {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
}
.inline-sub {
font-size: 24rpx;
font-weight: 500;
color: #64748b;
line-height: 1.35;
}
.inline-wave {
width: 120rpx;
height: 48rpx;
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-grid {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
gap: 8rpx;
width: 100%;
}
.inline-item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 14rpx 4rpx 12rpx;
border-radius: 20rpx;
background: #f0fdf4;
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-sizing: border-box;
&:active {
transform: scale(0.97);
opacity: 0.9;
}
.menu-icon {
width: 56rpx;
height: 56rpx;
margin-bottom: 8rpx;
flex-shrink: 0;
}
}
.inline-item-title {
font-size: 22rpx;
font-weight: 700;
color: #0f172a;
text-align: center;
line-height: 1.2;
width: 100%;
word-break: break-all;
}
.inline-item-sub {
margin-top: 2rpx;
font-size: 18rpx;
color: #94a3b8;
text-align: center;
line-height: 1.2;
width: 100%;
word-break: break-all;
}
</style>
+11 -5
View File
@@ -1,5 +1,11 @@
import App from './App'
var baseUrl ='https://css.zhenyangtang.com.cn/';
var baseUrl ='https://admin.zhenyangtang.com.cn/';
function joinApiUrl(base, path) {
const b = String(base || '').replace(/\/+$/, '')
const p = String(path || '').replace(/^\/+/, '')
return p ? `${b}/${p}` : b
}
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
@@ -20,13 +26,13 @@ Vue.prototype.apiUrl =function apiurl(promise, Loading = true){
if (promise.data) {
data = promise.data
}
let url=Vue.prototype.$url+promise.url
let url = joinApiUrl(Vue.prototype.$url, promise.url)
return new Promise(function(resolve, reject) {
uni.request({
url: url,
data: data,
timeout: 15000,
timeout: promise.timeout != null ? promise.timeout : 15000,
method: promise.method ? promise.method : 'POST',
header: header,
sslVerify: false,
@@ -73,7 +79,7 @@ function apiUrl(promise, Loading = true) {
// 处理请求数据和URL
const data = promise.data || {}
const url = baseUrl + (promise.url || '')
const url = joinApiUrl(baseUrl, promise.url || '')
return new Promise((resolve, reject) => {
// 可选:根据Loading参数显示加载中
@@ -84,7 +90,7 @@ function apiUrl(promise, Loading = true) {
uni.request({
url: url,
data: data,
timeout: 15000,
timeout: promise.timeout != null ? promise.timeout : 15000,
method: promise.method || 'POST', // 简化三元表达式
header: header,
sslVerify: false,
+1
View File
@@ -58,6 +58,7 @@
"subPackages" : true
},
"usingComponents" : true,
"requiredBackgroundModes" : ["audio"],
"plugins" : {
"WechatSI" : {
+10471 -1784
View File
File diff suppressed because it is too large Load Diff
+41 -3
View File
@@ -1,17 +1,55 @@
{
"name": "tuicallkit-vue3",
"version": "1.0.0",
"description": "",
"description": "TUICallKit Vue3 uni-app 工程(兼容 HBuilderX & vite CLI",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"dev:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni -p mp-weixin",
"dev:h5": "UNI_INPUT_DIR=\"$PWD\" uni",
"dev:app": "UNI_INPUT_DIR=\"$PWD\" uni -p app",
"build:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni build -p mp-weixin",
"build:h5": "UNI_INPUT_DIR=\"$PWD\" uni build",
"build:app": "UNI_INPUT_DIR=\"$PWD\" uni build -p app"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001",
"@dcloudio/uni-app-harmony": "3.0.0-4060620250520001",
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
"@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-alipay": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-baidu": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-jd": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-lark": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-qq": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-xhs": "3.0.0-4060620250520001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
"@trtc/calls-uikit-wx-uniapp": "^4.1.0",
"@uni-ku/bundle-optimizer": "^1.3.16",
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9"
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"@types/node": "^20.11.0",
"@vue/runtime-core": "^3.4.21",
"less": "^4.6.4",
"sass": "^1.77.8",
"typescript": "^5.4.0",
"vite": "^5.2.8"
},
"engines": {
"node": ">=18"
}
}
+91 -32
View File
@@ -3,7 +3,9 @@
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "甄养堂"
"navigationBarTitleText": "甄养堂",
"backgroundColor": "#F8F8F8",
"backgroundColorTop": "#F8F8F8"
}
},
{
@@ -43,7 +45,9 @@
{
"path": "pages/user/user",
"style": {
"navigationBarTitleText": "我的"
"navigationBarTitleText": "我的",
"backgroundColor": "#e8eaed",
"backgroundColorTop": "#e8eaed"
}
},
{
@@ -110,19 +114,48 @@
"root": "training",
"pages": [
{
"path": "pages/index",
"path": "pages/grip-ring",
"style": {
"navigationBarTitleText": "练一练"
}
},
{
"path": "pages/metronome",
"style": {
"navigationBarTitleText": "节拍器",
"navigationBarTitleText": "握力环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc",
"requiredBackgroundModes": ["audio"]
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/pilates-ring",
"style": {
"navigationBarTitleText": "瑜伽环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/foot-pedal",
"style": {
"navigationBarTitleText": "脚蹬器训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/dumbbell",
"style": {
"navigationBarTitleText": "哑铃训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/metronome",
"style": {
"navigationBarTitleText": "耗糖节拍器",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
}
]
@@ -133,33 +166,59 @@
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "日常记录",
"navigationBarBackgroundColor": "#0ea5a4",
"navigationBarTitleText": "血糖记录",
"navigationBarBackgroundColor": "#204e2b",
"navigationBarTextStyle": "white",
"backgroundColor": "#f1f5f9",
"backgroundColor": "#faf9f5",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/weekly",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "血糖管理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/more",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "日常护理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true
}
},
{
"path": "pages/game",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "糖分突袭",
"backgroundColor": "#c7d2fe",
"disableScroll": true
}
}
]
}
],
"tabBar": {
"color": "#8a8a8a",
"selectedColor": "#204e2b",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [{
"pagePath": "pages/index/index",
"iconPath": "/static/user/home.png",
"selectedIconPath": "/static/user/home_no.png",
"text": "首页"
},{
"pagePath": "pages/user/user",
"iconPath": "/static/user/user.png",
"selectedIconPath": "/static/user/user_no.png",
"text": "我的"
}]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "甄养堂互联网医院",
+14 -1
View File
@@ -778,6 +778,7 @@ const showDatePicker = ref(false)
const diagnosisId = ref(null)
const patientId = ref(null)
const isAddMode = ref(false) // 新建模式
const returnUrl = ref('') // 保存成功后跳回(如日常记录页)
// 表单数据
const formData = ref({
@@ -1023,6 +1024,13 @@ const loadCardDetail = async () => {
const options = currentPage.options || {}
diagnosisId.value = options.id
isAddMode.value = options.add === '1' || options.add === 1
if (options.returnUrl) {
try {
returnUrl.value = decodeURIComponent(String(options.returnUrl))
} catch (e) {
returnUrl.value = String(options.returnUrl)
}
}
const userData = uni.getStorageSync('userData')
patientId.value = userData?.diagnosis?.patient_id
@@ -1302,7 +1310,12 @@ const submitForm = async () => {
}
uni.showToast({ title: isAddMode.value ? '创建成功' : '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
const back = returnUrl.value
if (back && back.startsWith('/')) {
uni.redirectTo({ url: back })
} else {
uni.navigateBack()
}
}, 1500)
} else {
uni.showToast({ title: res.msg || '保存失败', icon: 'none' })
+5 -2
View File
@@ -118,13 +118,15 @@
</view>
</scroll-view>
</view>
<TabBarDock :active="0" />
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getCurrentInstance } from 'vue'
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import { onShareAppMessage } from '@dcloudio/uni-app'
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
const { proxy } = getCurrentInstance()
const doctors = ref([])
@@ -263,7 +265,8 @@ $outline: #727970;
.container {
background: $background;
min-height: 100vh;
padding: 24rpx 32rpx 120rpx;
padding: 24rpx 32rpx;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
+8 -5
View File
@@ -1,5 +1,5 @@
<template>
<div class="mine-page">
<view class="mine-page">
<!-- 顶部用户信息卡片 -->
<div class="user-card" @click="navigateTo('/pages/user/userinfo')">
<div class="user-header">
@@ -90,16 +90,18 @@
</div>
<!-- DEV 开发悬浮入口生产环境自动隐藏 -->
<dev-training-entry />
</div>
<dev-training-entry :bottom="360" />
<TabBarDock :active="2" />
</view>
</template>
<script>
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
export default {
name: 'profileB',
components: { DevTrainingEntry },
components: { DevTrainingEntry, TabBarDock },
data() {
return {
userInfo: {
@@ -170,6 +172,7 @@ export default {
min-height: 100vh;
background: #e8eaed;
padding: 40rpx;
padding-bottom: calc(40rpx + 200rpx + env(safe-area-inset-bottom));
}
.user-card {
@@ -420,4 +423,4 @@ export default {
.bg-cyan { background: #13c2c2; }
.bg-pink { background: #eb2f96; }
.bg-gray { background: #666666; }
</style>
</style>
-213
View File
@@ -1,213 +0,0 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* 用小米 MiMo TTS 生成训练语音素材
*
* 文档: https://platform.xiaomimimo.com/docs/en-US/usage-guide/speech-synthesis-v2.5
* 接口: POST https://api.xiaomimimo.com/v1/chat/completions
*
* 准备:
* export MIMO_API_KEY=sk-your-key-here
* (或 export XIAOMI_API_KEY=...)
*
* 运行:
* cd uniapp
* node scripts/generate-voice.mjs # 默认音色 冰糖
* node scripts/generate-voice.mjs --voice 茉莉
* node scripts/generate-voice.mjs --force # 已存在的也重新生成
*
* 输出(分包):
* training/static/voice/numbers/{1..30}.mp3
* training/static/voice/prompts/{key}.mp3
*
* 需要 Node 18+(用内置 fetch
*/
import { mkdir, writeFile, access, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Buffer } from 'node:buffer'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const ROOT = join(__dirname, '..')
const VOICE_DIR = join(ROOT, 'training/static/voice')
const NUMBERS_DIR = join(VOICE_DIR, 'numbers')
const PROMPTS_DIR = join(VOICE_DIR, 'prompts')
// ============ 参数 ============
const args = process.argv.slice(2)
const FORCE = args.includes('--force')
const VOICE = pickArg('--voice') || '冰糖'
const MODEL = pickArg('--model') || 'mimo-v2.5-tts'
const FORMAT = pickArg('--format') || 'mp3'
const API_KEY =
process.env.MIMO_API_KEY ||
process.env.XIAOMI_API_KEY ||
process.env.XIAOMI_MIMO_API_KEY
const ENDPOINT =
process.env.MIMO_ENDPOINT || 'https://api.xiaomimimo.com/v1/chat/completions'
if (!API_KEY) {
console.error('❌ 未找到 API Key')
console.error(' 请先 export MIMO_API_KEY=your-key')
process.exit(1)
}
function pickArg(name) {
const idx = args.indexOf(name)
if (idx === -1) return null
return args[idx + 1]
}
// ============ 语音内容 ============
// 数字报数:简短的"教练点数"风格
const NUMBER_STYLE =
'用简短有力的健身教练口吻报数,干净利落,节奏明快,每个数字独立清晰。'
// 口令:温柔有鼓励性的女教练
const PROMPT_STYLE =
'用温柔但有力量的女教练口吻说话,语调亲切自然,节奏适中,像在带学员训练。'
const PROMPTS = {
start: '开始',
ready: '准备',
rest: '休息一下',
'next-set': '下一组开始',
'last-rep': '最后一次',
'keep-it-up': '继续坚持',
'good-job': '很棒,训练完成',
'breathe-in': '吸气',
'breathe-out': '呼气',
}
// ============ 工具 ============
async function fileExists(p) {
try {
await access(p)
return true
} catch {
return false
}
}
async function ensureDir(d) {
await mkdir(d, { recursive: true })
}
async function tts(text, outPath, styleInstruction) {
if (!FORCE && (await fileExists(outPath))) {
const s = await stat(outPath)
if (s.size > 0) {
console.log(`[skip] ${outPath} (已存在)`)
return
}
}
const body = {
model: MODEL,
messages: [
{ role: 'user', content: styleInstruction },
{ role: 'assistant', content: text },
],
audio: { format: FORMAT, voice: VOICE },
}
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'api-key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const errText = await res.text().catch(() => '')
throw new Error(`HTTP ${res.status} ${res.statusText}: ${errText.slice(0, 300)}`)
}
const json = await res.json()
const audioBase64 = json?.choices?.[0]?.message?.audio?.data
if (!audioBase64) {
throw new Error(
`响应里找不到 audio.data 字段。完整响应: ${JSON.stringify(json).slice(0, 500)}`,
)
}
const buf = Buffer.from(audioBase64, 'base64')
await writeFile(outPath, buf)
console.log(`[ok] ${outPath} (${buf.length} bytes) → "${text}"`)
}
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
// ============ 主流程 ============
async function main() {
console.log('====== 小米 MiMo TTS 语音生成 ======')
console.log(`Endpoint : ${ENDPOINT}`)
console.log(`Model : ${MODEL}`)
console.log(`Voice : ${VOICE}`)
console.log(`Format : ${FORMAT}`)
console.log(`Force : ${FORCE}`)
console.log(`Output : ${VOICE_DIR}`)
console.log('====================================\n')
await ensureDir(NUMBERS_DIR)
await ensureDir(PROMPTS_DIR)
let successCount = 0
let failCount = 0
console.log('--- 数字 1-30 ---')
for (let n = 1; n <= 30; n++) {
try {
await tts(String(n), join(NUMBERS_DIR, `${n}.${FORMAT}`), NUMBER_STYLE)
successCount++
await sleep(120)
} catch (e) {
failCount++
console.error(`[fail] 数字 ${n}: ${e.message}`)
}
}
console.log('\n--- 口令 ---')
for (const [key, text] of Object.entries(PROMPTS)) {
try {
await tts(text, join(PROMPTS_DIR, `${key}.${FORMAT}`), PROMPT_STYLE)
successCount++
await sleep(120)
} catch (e) {
failCount++
console.error(`[fail] ${key}: ${e.message}`)
}
}
console.log('\n====================================')
console.log(`成功 ${successCount} | 失败 ${failCount}`)
console.log('====================================')
if (FORMAT !== 'mp3') {
console.log(
`\n⚠️ 当前生成格式是 ${FORMAT},但 hooks/useVoiceCoach.ts 默认引用 .mp3。`,
)
console.log(' 要么换 --format mp3 重跑,要么修改 hooks 里的 src 后缀。')
}
console.log('\n下一步:')
console.log(' 1. 准备 click 音 → training/static/audio/click.mp3')
console.log(' 2. 准备 BGM → training/static/bgm/{train-light,rest-meditation}.mp3')
console.log(' 3. 编译运行 → 进入 /training/pages/index')
}
main().catch((e) => {
console.error('\n❌ 致命错误:', e)
process.exit(1)
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,142 @@
<template>
<view v-if="show" class="celebrate-root" @touchmove.stop.prevent>
<view
v-for="p in particles"
:key="p.id"
class="celebrate-particle"
:style="p.style"
/>
<view v-if="title" class="celebrate-card" :class="{ pop: cardPop }">
<view class="celebrate-card-glow" />
<TongjiIcon name="sparkles" size="lg" color="#204E2B" />
<text class="celebrate-card-title">{{ title }}</text>
<text v-if="subtitle" class="celebrate-card-sub">{{ subtitle }}</text>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
import TongjiIcon from './TongjiIcon.vue'
const props = defineProps({
show: { type: Boolean, default: false },
title: { type: String, default: '' },
subtitle: { type: String, default: '' }
})
const particles = ref([])
const cardPop = ref(false)
const COLORS = ['#204E2B', '#386641', '#AFE2B3', '#FBBF24', '#727970', '#DC2626']
function buildParticles() {
const list = []
for (let i = 0; i < 18; i++) {
const left = 8 + Math.random() * 84
const delay = Math.random() * 0.35
const hue = COLORS[i % COLORS.length]
const size = 10 + Math.floor(Math.random() * 14)
list.push({
id: `${Date.now()}_${i}`,
style: {
left: `${left}%`,
width: `${size}rpx`,
height: `${size}rpx`,
background: hue,
animationDelay: `${delay}s`
}
})
}
return list
}
watch(
() => props.show,
(v) => {
if (v) {
particles.value = buildParticles()
cardPop.value = false
setTimeout(() => {
cardPop.value = true
}, 30)
} else {
particles.value = []
cardPop.value = false
}
}
)
</script>
<style lang="scss" scoped>
.celebrate-root {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
overflow: hidden;
}
.celebrate-particle {
position: absolute;
top: -20rpx;
border-radius: 4rpx;
opacity: 0.9;
animation: celebrate-fall 1.6s ease-in forwards;
}
@keyframes celebrate-fall {
0% {
transform: translateY(0) rotate(0deg) scale(1);
opacity: 1;
}
100% {
transform: translateY(110vh) rotate(540deg) scale(0.4);
opacity: 0;
}
}
.celebrate-card {
position: absolute;
left: 50%;
top: 38%;
transform: translate(-50%, -50%) scale(0.82);
width: 78%;
max-width: 560rpx;
padding: 36rpx 32rpx 32rpx;
background: rgba(255, 255, 255, 0.96);
border-radius: 28rpx;
box-shadow: 0 20rpx 60rpx rgba(8, 145, 178, 0.22);
border: 2rpx solid rgba(8, 145, 178, 0.12);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
opacity: 0;
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
&.pop {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.celebrate-card-glow {
position: absolute;
inset: -20rpx;
border-radius: 36rpx;
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 70%);
pointer-events: none;
}
.celebrate-card-title {
position: relative;
z-index: 1;
margin-top: 16rpx;
font-size: 36rpx;
font-weight: 800;
color: #1b1c1a;
}
.celebrate-card-sub {
position: relative;
z-index: 1;
margin-top: 10rpx;
font-size: 26rpx;
color: #475569;
line-height: 1.45;
}
</style>
@@ -0,0 +1,116 @@
<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,199 @@
<template>
<view
class="sugar-tree-graphic"
:class="[`lv-${clampedLevel}`, `tier-${visualTier}`, `size-${size}`, { watering: watering, 'is-max': clampedLevel >= MAX_LEVEL }]"
>
<view class="stg-glow" />
<view v-if="clampedLevel >= 7" class="stg-aura" />
<image v-if="!treeUseFallback" class="stg-image" :src="treeImageSrc" mode="aspectFit" @error="treeUseFallback = true" />
<text v-else class="stg-emoji">{{ treeEmoji }}</text>
<view v-if="clampedLevel >= 7" class="stg-sparkle stg-sparkle-a" />
<view v-if="clampedLevel >= 8" class="stg-sparkle stg-sparkle-b" />
<view v-if="clampedLevel >= MAX_LEVEL" class="stg-sparkle stg-sparkle-c" />
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
import { TREE_MAX_LEVEL, TREE_LEVELS } from '../utils/treeLevels.js'
const MAX_LEVEL = TREE_MAX_LEVEL
// #ifdef MP-WEIXIN
const treeUseFallback = ref(true)
// #endif
// #ifndef MP-WEIXIN
const treeUseFallback = ref(false)
// #endif
const props = defineProps({
level: { type: Number, default: 0 },
size: { type: String, default: 'md' },
watering: { type: Boolean, default: false }
})
const clampedLevel = computed(() => Math.min(MAX_LEVEL, Math.max(0, Number(props.level) || 0)))
const visualTier = computed(() => {
const lv = clampedLevel.value
if (lv <= 0) return 0
if (lv <= 2) return 1
if (lv <= 4) return 2
if (lv <= 6) return 3
if (lv <= 8) return 4
return 5
})
const treeEmoji = computed(() => (TREE_LEVELS[clampedLevel.value] || TREE_LEVELS[0]).emoji)
function buildTreeSvg(level) {
const pot = `
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#204e2b" opacity="0.12"/>
<path d="M11 46h26c1.2 0 2 1 2 2.2v3.8c0 1-.8 1.8-1.8 1.8H10.8c-1 0-1.8-.8-1.8-1.8v-3.8c0-1.2.8-2.2 2-2.2z" fill="#E7E5E4"/>
<path d="M12.5 46h23c.8 0 1.5.7 1.5 1.5v2.2c0 .6-.5 1.1-1.1 1.1H12.1c-.6 0-1.1-.5-1.1-1.1v-2.2c0-.8.7-1.5 1.5-1.5z" fill="#D6D3D1"/>
<ellipse cx="24" cy="46.5" rx="9" ry="1.6" fill="#A8A29E" opacity="0.35"/>
`
const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#386641" opacity="0.18"/>`
if (level <= 0) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">
${pot}${soil}
<circle cx="24" cy="41.5" r="2.2" fill="#a8a29e" opacity="0.7"/>
<path d="M24 41.5v3.5" stroke="#78716c" stroke-width="1" stroke-linecap="round"/>
</svg>`
}
const trunkH = level >= 6 ? 16 : level >= 3 ? 14 : 10
const trunkY = 46 - trunkH
const trunk = `<rect x="22.2" y="${trunkY}" width="3.6" height="${trunkH}" rx="1.8" fill="#78716C"/>
<rect x="22.6" y="${trunkY + 1}" width="2.8" height="${trunkH - 1}" rx="1.4" fill="#A8A29E" opacity="0.35"/>`
const leaf = (cx, cy, r, fill, opacity = 1) =>
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" opacity="${opacity}"/>
<circle cx="${cx - r * 0.25}" cy="${cy - r * 0.2}" r="${r * 0.35}" fill="#eef6ef" opacity="0.55"/>`
const bloom =
level >= 7
? leaf(17, 18, 3, '#fda4af', 0.95) +
leaf(31, 17, 2.8, '#f9a8d4', 0.9) +
leaf(24, 13, 3.2, '#fb7185', 0.95) +
`<circle cx="24" cy="12" r="1.3" fill="#fef3c7"/>`
: ''
const crown =
level >= 9
? leaf(12, 22, 6, '#34d399', 0.9) +
leaf(36, 22, 6, '#34d399', 0.9) +
leaf(24, 10, 7, '#386641', 0.95) +
bloom
: bloom
let canopy = ''
if (level === 1) {
canopy = leaf(24, 38, 4, '#afe2b3') + `<path d="M24 38v-5" stroke="#386641" stroke-width="1.2" stroke-linecap="round"/>`
} else if (level === 2) {
canopy = leaf(24, 32, 5.5, '#34d399') + leaf(20, 34, 3.5, '#6ee7b7', 0.9)
} else if (level <= 4) {
canopy =
leaf(24, 28, 7, '#34d399') +
leaf(17, 30, 5, '#6ee7b7', 0.9) +
leaf(31, 30, 5, '#6ee7b7', 0.9)
} else if (level <= 6) {
canopy =
leaf(24, 24, 9, '#22c55e') +
leaf(14, 26, 7, '#4ade80', 0.92) +
leaf(34, 26, 7, '#4ade80', 0.92) +
leaf(24, 16, 6, '#386641', 0.88)
} else {
canopy =
leaf(24, 20, 10, '#204e2b') +
leaf(13, 24, 8, '#34d399', 0.95) +
leaf(35, 24, 8, '#34d399', 0.95) +
crown
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">${pot}${soil}${trunk}${canopy}</svg>`
}
const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value)))
</script>
<style lang="scss" scoped>
.sugar-tree-graphic {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.size-sm { width: 64rpx; height: 72rpx; }
.size-md { width: 80rpx; height: 88rpx; }
.size-lg { width: 112rpx; height: 124rpx; }
.stg-glow {
position: absolute;
inset: 6%;
border-radius: 50%;
background: radial-gradient(circle, rgba(32, 78, 43, 0.22) 0%, transparent 68%);
pointer-events: none;
}
.lv-0 .stg-glow { background: radial-gradient(circle, rgba(148, 163, 184, 0.25) 0%, transparent 70%); }
.tier-4 .stg-glow,
.tier-5 .stg-glow,
.is-max .stg-glow {
background: radial-gradient(circle, rgba(251, 191, 36, 0.3) 0%, rgba(32, 78, 43, 0.15) 55%, transparent 72%);
}
.stg-aura {
position: absolute;
inset: -8%;
border-radius: 50%;
border: 2rpx solid rgba(253, 224, 71, 0.35);
animation: stg-aura-pulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes stg-aura-pulse {
0%, 100% { transform: scale(0.92); opacity: 0.5; }
50% { transform: scale(1.05); opacity: 1; }
}
.stg-image {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
transition: transform 0.35s ease;
}
.sugar-tree-graphic.watering .stg-image,
.sugar-tree-graphic.watering .stg-emoji {
animation: stg-water-bounce 0.65s ease;
}
.stg-emoji {
position: relative;
z-index: 1;
line-height: 1;
font-size: 72rpx;
}
.size-sm .stg-emoji { font-size: 48rpx; }
.size-md .stg-emoji { font-size: 60rpx; }
.size-lg .stg-emoji { font-size: 88rpx; }
@keyframes stg-water-bounce {
0%, 100% { transform: scale(1); }
35% { transform: scale(1.1) translateY(-6rpx); }
60% { transform: scale(0.96) translateY(2rpx); }
}
.stg-sparkle {
position: absolute;
z-index: 2;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #fde68a;
box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8);
pointer-events: none;
animation: stg-sparkle-twinkle 1.8s ease-in-out infinite;
}
.stg-sparkle-a { top: 4%; right: 16%; }
.stg-sparkle-b { top: 12%; left: 10%; width: 6rpx; height: 6rpx; animation-delay: 0.4s; }
.stg-sparkle-c { top: 22%; right: 28%; width: 10rpx; height: 10rpx; animation-delay: 0.8s; }
@keyframes stg-sparkle-twinkle {
0%, 100% { opacity: 0.4; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
</style>
@@ -0,0 +1,190 @@
<template>
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
<image
v-if="!useFallback"
class="tj-icon"
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
:src="iconSrc"
mode="aspectFit"
@error="onImageError"
/>
<!-- <text
v-else
class="tj-icon-fallback"
:class="[`tj-icon-fallback--${size}`]"
:style="{ color }"
>{{ fallbackGlyph }}</text> -->
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
const props = defineProps({
name: { type: String, required: true },
size: { type: String, default: 'md' },
color: { type: String, default: '#204E2B' }
})
const useFallback = ref(false)
/** Lucide 风格描边路径 */
const ICON_PATHS = {
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
minus: '<path d="M5 12h14"/>',
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
plus: '<path d="M5 12h14M12 5v14"/>',
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
}
/** 图片加载失败时的 emoji 回退 */
const ICON_FALLBACK = {
view: '👁',
ticket: '🎫',
calendar: '📅',
flame: '🔥',
'check-circle': '✓',
glucose: '💧',
heart: '❤',
activity: '🏃',
users: '👥',
user: '👤',
person: '👤',
send: '➤',
'alert-triangle': '⚠',
minus: '—',
droplet: '💧',
sparkles: '✨',
trophy: '🏆',
share: '↗',
plus: '',
refresh: '↻',
volume: '🔊',
pause: '⏸',
play: '▶',
info: '!',
sun: '☀',
moon: '🌙',
sunset: '☀',
mic: '🎤',
camera: '📷',
bulb: '💡',
leaf: '🥬',
utensils: '🍴',
egg: '🍳',
home: '🏠',
'plus-circle': '⊕',
'chevron-right': '',
'chevron-left': '',
check: '✓',
settings: '⚙',
syringe: '💉',
zap: '⚡'
}
const strokeColor = computed(() => {
const c = String(props.color || '#204E2B').trim()
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
})
const iconSrc = computed(() => {
const path = ICON_PATHS[props.name] || ICON_PATHS.view
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
return svgToDataUrl(svg)
})
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
function onImageError() {
useFallback.value = true
}
</script>
<style lang="scss" scoped>
.tj-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tj-icon-wrap--sm {
width: 32rpx;
height: 32rpx;
}
.tj-icon-wrap--md {
width: 40rpx;
height: 40rpx;
}
.tj-icon-wrap--lg {
width: 48rpx;
height: 48rpx;
}
.tj-icon-wrap--xl {
width: 56rpx;
height: 56rpx;
}
.tj-icon {
display: block;
width: 100%;
height: 100%;
}
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
.tj-icon--send {
transform: translate(-10%, 10%);
}
.tj-icon-fallback {
display: block;
line-height: 1;
font-weight: 700;
text-align: center;
}
.tj-icon-fallback--sm {
font-size: 24rpx;
}
.tj-icon-fallback--md {
font-size: 30rpx;
}
.tj-icon-fallback--lg {
font-size: 36rpx;
}
.tj-icon-fallback--xl {
font-size: 42rpx;
}
</style>
@@ -0,0 +1,312 @@
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialDietPlainText } from '../utils/aiStreamRequest.js'
const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
const emptyDietAiRecommend = () => ({
breakfast: '',
lunch: '',
dinner: '',
tips: '',
avoid: [],
analysis: [],
exercise: null,
disclaimer: '',
rules_summary: '',
source: '',
date: ''
})
/** AI 饮食助手(今日推荐 + 能不能吃) */
export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) {
const dietAiRecommend = ref(emptyDietAiRecommend())
const dietAiLoading = ref(false)
const dietAiStreaming = ref(false)
const dietAiReplacing = ref(false)
const dietAiStreamField = ref('')
let dietStreamBuffer = ''
const dietAiAskText = ref('')
const dietAiAsking = ref(false)
const dietAiAskResult = ref({ advice: '', level: '', level_label: '', portion: '', food: '' })
const hasDietAiMealContent = computed(() => {
const r = dietAiRecommend.value
return !!(r.breakfast || r.lunch || r.dinner || r.tips)
})
function detectDietStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (!tail) return 'breakfast'
const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
return map[tail[1]] || 'breakfast'
}
function mergePartialDietFromStream() {
const partial = parsePartialDietPlainText(dietStreamBuffer)
const next = { ...dietAiRecommend.value }
for (const key of ['breakfast', 'lunch', 'dinner', 'tips']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
dietAiRecommend.value = next
dietAiStreamField.value = detectDietStreamField(dietStreamBuffer)
}
function applyDietRecommendPayload(payload) {
if (!payload) return
const prevAnalysis = dietAiRecommend.value.analysis
const prevExercise = dietAiRecommend.value.exercise
dietAiRecommend.value = {
...emptyDietAiRecommend(),
...payload,
avoid: Array.isArray(payload.avoid) ? payload.avoid : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : []),
exercise: (payload.exercise && typeof payload.exercise === 'object')
? payload.exercise
: (prevExercise || null)
}
}
function resolveDietDonePayload(payload) {
if (!payload) return payload
if (payload.source !== 'rule' || !dietStreamBuffer) return payload
const streamed = parsePartialDietPlainText(dietStreamBuffer)
if (!streamed.breakfast) return payload
return {
...emptyDietAiRecommend(),
...payload,
breakfast: streamed.breakfast,
lunch: streamed.lunch || payload.lunch || '',
dinner: streamed.dinner || payload.dinner || '',
tips: streamed.tips || payload.tips || '',
avoid: (Array.isArray(streamed.avoid) && streamed.avoid.length) ? streamed.avoid : (payload.avoid || []),
source: 'ai',
disclaimer: 'AI 建议供参考,仍请结合医嘱与血糖监测。'
}
}
function onDietAiStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
dietAiRecommend.value = { ...dietAiRecommend.value, analysis: payload.analysis }
}
} else if (event === 'exercise') {
if (payload?.exercise && typeof payload.exercise === 'object') {
dietAiRecommend.value = { ...dietAiRecommend.value, exercise: payload.exercise }
}
} else if (event === 'delta') {
dietAiStreaming.value = true
if (payload.buffer != null) {
dietStreamBuffer = payload.buffer
} else {
dietStreamBuffer += payload.text || ''
}
mergePartialDietFromStream()
} else if (event === 'done') {
applyDietRecommendPayload(resolveDietDonePayload(payload))
dietStreamBuffer = ''
dietAiStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '饮食建议加载失败'))
}
}
async function fetchDietAiRecommendFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDietAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyDietRecommendPayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
async function fetchDietAiRecommend(refresh = false) {
if (!diagnosisId.value || dietAiLoading.value) return
dietAiLoading.value = true
dietAiStreaming.value = false
dietAiReplacing.value = !!refresh
dietStreamBuffer = ''
dietAiStreamField.value = ''
if (refresh) {
applyDietRecommendPayload(emptyDietAiRecommend())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyDietAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onDietAiStreamEvent,
fallback: () => fetchDietAiRecommendFallback(refresh)
})
} catch (e) {
try {
await fetchDietAiRecommendFallback(refresh)
} catch (e2) {
showUserToast('饮食建议加载失败')
}
} finally {
dietAiLoading.value = false
dietAiStreaming.value = false
dietAiReplacing.value = false
dietAiStreamField.value = ''
}
}
function refreshDietAiRecommend() {
if (dietAiLoading.value) return
fetchDietAiRecommend(true)
}
async function askDietAiFoodFallback(q) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyDietAiAsk',
method: 'POST',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
question: q
}
}, false)
if (res?.code === 1 && res.data) {
dietAiAskResult.value = {
advice: res.data.advice || '',
level: res.data.level || '',
level_label: res.data.level_label || '',
portion: res.data.portion || '',
food: res.data.food || q
}
} else {
showUserToast(formatUserMessage(res?.msg, '咨询失败'))
}
}
async function askDietAiFood() {
const q = String(dietAiAskText.value || '').trim()
if (!q) {
showUserToast('请输入想咨询的食物')
return
}
if (!diagnosisId.value) return
dietAiAsking.value = true
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyDietAiAskStream',
method: 'POST',
data: {
diagnosis_id: diagnosisId.value,
question: q
},
onEvent: (event, payload) => {
if (event === 'start' && !dietAiAskResult.value.advice) {
dietAiAskResult.value = { ...dietAiAskResult.value, advice: payload.message || '正在想…' }
} else if (event === 'delta') {
dietAiAskResult.value = {
...dietAiAskResult.value,
advice: payload.advice || dietAiAskResult.value.advice
}
} else if (event === 'done') {
dietAiAskResult.value = {
advice: payload.advice || '',
level: payload.level || '',
level_label: payload.level_label || '',
portion: payload.portion || '',
food: payload.food || q
}
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '咨询失败'))
}
},
fallback: () => askDietAiFoodFallback(q)
})
} catch (e) {
try {
await askDietAiFoodFallback(q)
} catch (e2) {
showUserToast('网络异常,请稍后再试')
}
} finally {
dietAiAsking.value = false
}
}
function onDietAiAskInput(e) {
dietAiAskText.value = e?.detail?.value ?? ''
}
function saveDietPrefillToStorage() {
const r = dietAiRecommend.value
if (!r.breakfast) return
try {
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
breakfast: r.breakfast,
lunch: r.lunch,
dinner: r.dinner,
tips: r.tips || ''
})
} catch (e) {}
}
function goMoreDietForm(withPrefill = false) {
if (withPrefill) saveDietPrefillToStorage()
uni.navigateTo({ url: '/tongji/pages/more?openDiet=1' })
}
async function applyDietAiToForm() {
if (!dietAiRecommend.value.breakfast) {
await fetchDietAiRecommend(false)
}
goMoreDietForm(true)
}
function openDietForm() {
goMoreDietForm(false)
}
return {
dietAiRecommend,
dietAiLoading,
dietAiStreaming,
dietAiReplacing,
dietAiStreamField,
dietAiAskText,
dietAiAsking,
dietAiAskResult,
hasDietAiMealContent,
fetchDietAiRecommend,
refreshDietAiRecommend,
askDietAiFood,
onDietAiAskInput,
applyDietAiToForm,
openDietForm
}
}
/** more 页打开饮食表单时读取并清除预填 */
export function consumeDietAiPrefill() {
try {
const data = uni.getStorageSync(DIET_AI_PREFILL_KEY)
uni.removeStorageSync(DIET_AI_PREFILL_KEY)
if (data && typeof data === 'object' && data.breakfast) return data
} catch (e) {}
return null
}
@@ -0,0 +1,188 @@
/**
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
*/
import { ref } from 'vue'
let fxSeq = 0
function nextFxId(prefix) {
fxSeq += 1
return `${prefix}-${fxSeq}-${Date.now()}`
}
// 开心消消乐式糖果色板(彩虹爆裂用)
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
// 星屑字形
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
export function useGameFx() {
const floatingTexts = ref([])
const burstParticles = ref([])
const ripples = ref([])
const comboBanner = ref({ show: false, text: '', tier: 1 })
const screenShaking = ref(false)
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
let comboBannerTimer = null
let shakeTimer = null
let flashTimer = null
let flashFadeTimer = null
function spawnFloatingText(payload) {
const id = nextFxId('ft')
floatingTexts.value.push({ id, ...payload })
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
setTimeout(() => {
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
}, duration)
return id
}
/**
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
* @param {number} cx,cy 爆裂中心(屏幕坐标)
* @param {string} color 基础颜色
* @param {number} count 粒子数量
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
*/
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
const {
star = true,
rainbow = false,
gravity = 64,
sizeMin = 8,
sizeMax = 16,
distMin = 36,
distMax = 110,
life = 850
} = opts
const dur = (life / 1000).toFixed(2)
for (let i = 0; i < count; i++) {
const id = nextFxId('p')
const isStar = star && Math.random() < 0.65
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
const angle = Math.random() * Math.PI * 2
const distance = Math.random() * (distMax - distMin) + distMin
const tx = Math.cos(angle) * distance
// 先上抛、再叠加重力下落,形成喷泉抛物线
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
const half = pSize / 2
const pColor = rainbow
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
: color
const rot = Math.random() * 720 - 360
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
const particle = {
id,
left: cx - half,
top: cy - half,
size: pSize,
color: pColor,
char: glyph,
opacity: 1,
transform: 'scale(0.4) rotate(0deg)',
transition: 'none'
}
burstParticles.value.push(particle)
setTimeout(() => {
particle.transition =
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
particle.left = cx - half + tx
particle.top = cy - half + ty
particle.opacity = 0
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
}, 16)
setTimeout(() => {
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
}, life + 80)
}
}
/**
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
* @param {object} opts { thickness, life }
*/
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
const id = nextFxId('rp')
const size = 36 + tier * 18
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
ripples.value.push({
id,
left: cx,
top: cy,
size,
color,
thickness,
opacity: 0.95
})
setTimeout(() => {
ripples.value = ripples.value.filter((r) => r.id !== id)
}, life)
}
/**
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
*/
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
if (flashFadeTimer) clearTimeout(flashFadeTimer)
if (flashTimer) clearTimeout(flashTimer)
flash.value = { active: true, color, opacity: intensity }
flashFadeTimer = setTimeout(() => {
flash.value = { ...flash.value, opacity: 0 }
}, 24)
flashTimer = setTimeout(() => {
flash.value = { active: false, color, opacity: 0 }
}, life)
}
function showComboBanner(chain, points) {
if (chain <= 1) return
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
comboBanner.value = {
show: true,
text: `连消 ×${chain} +${points}`,
tier
}
if (comboBannerTimer) clearTimeout(comboBannerTimer)
comboBannerTimer = setTimeout(() => {
comboBanner.value = { ...comboBanner.value, show: false }
}, 1100)
}
function triggerShake(intensity = 1) {
screenShaking.value = true
if (shakeTimer) clearTimeout(shakeTimer)
shakeTimer = setTimeout(() => {
screenShaking.value = false
}, intensity >= 2 ? 420 : 280)
}
function clearAll() {
floatingTexts.value = []
burstParticles.value = []
ripples.value = []
comboBanner.value = { show: false, text: '', tier: 1 }
screenShaking.value = false
flash.value = { active: false, color: '#ffffff', opacity: 0 }
}
return {
floatingTexts,
burstParticles,
ripples,
comboBanner,
screenShaking,
flash,
spawnFloatingText,
spawnBurst,
spawnRipple,
showComboBanner,
triggerShake,
triggerFlash,
clearAll
}
}
@@ -0,0 +1,112 @@
/**
* 游戏进度持久化:关卡、星级、连胜、每日打卡
*/
import { ref } from 'vue'
import { calcLevelStars } from '../data/gameLevels.js'
const STORAGE_KEY = 'tongji_game_progress_v1'
const DEFAULT = {
currentLevel: 1,
maxUnlocked: 1,
stars: {},
winStreak: 0,
dailyStreak: 0,
lastPlayDate: '',
totalWins: 0,
bestCombo: 0,
totalStars: 0
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function sumStars(starsMap) {
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
}
function loadRaw() {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
} catch (_) {}
return { ...DEFAULT }
}
function saveRaw(data) {
try {
uni.setStorageSync(STORAGE_KEY, data)
} catch (_) {}
}
export function useGameProgress() {
const progress = ref(loadRaw())
function persist() {
progress.value.totalStars = sumStars(progress.value.stars)
saveRaw(progress.value)
}
/** 进入游戏时更新每日打卡 */
function touchDailyPlay() {
const today = todayStr()
const last = progress.value.lastPlayDate
if (last === today) return progress.value.dailyStreak
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
if (last === yStr) {
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
} else {
progress.value.dailyStreak = 1
}
progress.value.lastPlayDate = today
persist()
return progress.value.dailyStreak
}
function getStars(levelId) {
return progress.value.stars[String(levelId)] || 0
}
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
const key = String(levelId)
const prev = progress.value.stars[key] || 0
if (stars > prev) progress.value.stars[key] = stars
progress.value.winStreak = (progress.value.winStreak || 0) + 1
progress.value.totalWins = (progress.value.totalWins || 0) + 1
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
const next = levelId + 1
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
progress.value.currentLevel = next
persist()
return { stars, newBest: stars > prev }
}
function recordLoss() {
progress.value.winStreak = 0
persist()
}
function setCurrentLevel(levelId) {
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
persist()
}
return {
progress,
touchDailyPlay,
getStars,
recordWin,
recordLoss,
setCurrentLevel,
persist
}
}
@@ -0,0 +1,303 @@
/**
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
*/
import { ref, onUnmounted } from 'vue'
const STORAGE_KEY = 'tongji_game_sfx_enabled'
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
const SRC = {
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
}
/**
* 音效表:src / volume / rate(playbackRate) / pool
* rate 不同可在同一素材上听出明显差异
*/
const SFX = {
// —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
// —— 消除(按连数)——
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
// —— 消除(按 GI 主色)——
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
// —— 连消 ——
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
// —— 道具 ——
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
// —— 反馈 ——
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
// 兼容旧名
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
}
class SfxPool {
constructor(src, size, volume, rate = 1) {
this.src = src
this.size = size
this.volume = volume
this.rate = rate
this.list = []
this.cursor = 0
this.warmedUp = false
}
create() {
for (let i = 0; i < this.size; i++) {
const ctx = uni.createInnerAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.volume
try {
ctx.playbackRate = this.rate
} catch (_) {}
this.list.push(ctx)
}
}
warmUp() {
if (this.warmedUp) return
if (!this.list.length) this.create()
this.list.forEach((ctx) => {
try {
ctx.volume = 0
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = this.volume
} catch (_) {}
}, 60)
} catch (_) {}
})
this.warmedUp = true
}
play(scale = 1, rateMul = 1) {
if (!this.list.length) this.create()
const ctx = this.list[this.cursor % this.list.length]
this.cursor += 1
const vol = Math.min(1, this.volume * scale)
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
try {
ctx.volume = vol
try {
ctx.playbackRate = rate
} catch (_) {}
ctx.stop()
ctx.seek(0)
ctx.play()
} catch (_) {
try {
ctx.play()
} catch (e) {}
}
}
destroy() {
this.list.forEach((ctx) => {
try {
ctx.stop()
ctx.destroy()
} catch (_) {}
})
this.list = []
this.warmedUp = false
}
}
function dominantGi(tiles = []) {
const cnt = { low: 0, mid: 0, high: 0 }
tiles.forEach((t) => {
const g = t?.type?.gi
if (g && cnt[g] !== undefined) cnt[g] += 1
})
let best = 'mid'
let max = 0
Object.entries(cnt).forEach(([k, v]) => {
if (v > max) {
max = v
best = k
}
})
return best
}
export function useGameSfx() {
const enabled = ref(true)
const pools = {}
try {
const stored = uni.getStorageSync(STORAGE_KEY)
if (stored === false || stored === '0' || stored === 0) {
enabled.value = false
}
} catch (_) {}
function ensureAudioOption() {
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true
})
} catch (_) {}
// #endif
}
function getPool(name) {
const cfg = SFX[name]
if (!cfg) return null
if (!pools[name]) {
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
}
return pools[name]
}
function warmUp() {
if (!enabled.value) return
ensureAudioOption()
Object.keys(SFX).forEach((name) => {
getPool(name)?.warmUp()
})
}
function play(name, scale = 1, rateMul = 1) {
if (!enabled.value) return
getPool(name)?.play(scale, rateMul)
}
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
play(primary)
if (secondary) {
setTimeout(() => play(secondary, secScale), delayMs)
}
}
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
if (!enabled.value) return
const giTrack = (() => {
const gi = dominantGi(tiles)
if (gi === 'low') return 'matchLowGi'
if (gi === 'high') return 'matchHighGi'
return 'matchMidGi'
})()
if (chain >= 3) {
playLayer('comboMega', giTrack, 90, 0.55)
return
}
if (chain === 2) {
playLayer('combo3', giTrack, 80, 0.5)
return
}
if (chain === 1) {
playLayer('combo2', giTrack, 70, 0.45)
return
}
if (matchCount >= 5) {
playLayer('match5', 'matchHighGi', 85, 0.5)
} else if (matchCount >= 4) {
playLayer('match4', giTrack, 65, 0.48)
} else {
play(giTrack, 1, 1)
setTimeout(() => play('match3', 0.85), 40)
}
}
function playDrop(count = 1) {
if (!enabled.value || count <= 0) return
const name = count >= 4 ? 'drop' : 'dropLight'
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
}
function playMeter(delta, level) {
if (!enabled.value) return
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
else if (level < 30) play('meterWarn', 0.9, 0.95)
else if (delta > 8) play('meterWarn', 0.75)
else if (delta < -5) play('meterStable', 1.1)
}
function toggleEnabled() {
enabled.value = !enabled.value
try {
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
} catch (_) {}
if (enabled.value) {
warmUp()
play('select')
}
}
function destroyAll() {
Object.keys(pools).forEach((key) => {
pools[key]?.destroy()
delete pools[key]
})
}
onUnmounted(() => {
destroyAll()
})
return {
enabled,
play,
playLayer,
playMatch,
playDrop,
playMeter,
warmUp,
toggleEnabled,
destroyAll
}
}
@@ -0,0 +1,260 @@
import { ref, onUnmounted } from 'vue'
const MIN_HOLD_MS = 280
/**
* 语音转文字(长按说话):微信小程序 WechatSIH5 Web Speech API。
*
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
*/
export function useSpeechToText({ onResult, showToast } = {}) {
const sttListening = ref(false)
const sttHolding = ref(false)
const sttSupported = ref(false)
let wxRecordManager = null
let h5Recognition = null
let holdStartTs = 0
let holdSessionId = 0
let startRequested = false
let recordAuthorized = null
function notify(msg) {
if (!msg) return
if (typeof showToast === 'function') {
showToast(msg)
return
}
uni.showToast({ title: msg, icon: 'none', duration: 2000 })
}
function emitResult(text) {
const t = String(text || '').trim()
const heldMs = Date.now() - holdStartTs
if (!t) {
if (heldMs < MIN_HOLD_MS) return
notify('没听清,请再试一次')
return
}
if (typeof onResult === 'function') {
onResult(t)
}
}
function stopListening() {
// #ifdef MP-WEIXIN
if (wxRecordManager && (sttListening.value || startRequested)) {
try {
wxRecordManager.stop()
} catch (e) {}
}
// #endif
// #ifdef H5
if (h5Recognition && sttListening.value) {
try {
h5Recognition.stop()
} catch (e) {}
}
// #endif
startRequested = false
sttListening.value = false
}
function ensureRecordAuth(onGranted) {
// #ifdef MP-WEIXIN
if (recordAuthorized === true) {
onGranted()
return
}
uni.authorize({
scope: 'scope.record',
success() {
recordAuthorized = true
onGranted()
},
fail() {
recordAuthorized = false
sttHolding.value = false
uni.showModal({
title: '需要麦克风权限',
content: '请在设置中允许使用麦克风,以便长按说话',
confirmText: '去设置',
success(res) {
if (res.confirm) {
uni.openSetting({})
}
}
})
}
})
// #endif
// #ifndef MP-WEIXIN
onGranted()
// #endif
}
function startWechatRecord(session) {
// #ifdef MP-WEIXIN
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
return
}
try {
startRequested = true
wxRecordManager.start({
duration: 60000,
lang: 'zh_CN'
})
} catch (e) {
startRequested = false
sttHolding.value = false
notify('无法开始录音')
}
// #endif
}
function startH5Record() {
// #ifdef H5
if (!h5Recognition) {
notify('当前浏览器不支持语音输入')
sttHolding.value = false
return
}
try {
startRequested = true
h5Recognition.start()
sttListening.value = true
} catch (e) {
startRequested = false
sttHolding.value = false
notify('无法开始语音识别,请检查麦克风权限')
}
// #endif
}
/** 长按开始:手指按下 */
function beginHoldSpeech() {
if (!sttSupported.value) {
notify('当前环境不支持语音输入')
return
}
if (sttHolding.value) return
const session = ++holdSessionId
holdStartTs = Date.now()
sttHolding.value = true
// #ifdef MP-WEIXIN
ensureRecordAuth(() => startWechatRecord(session))
// #endif
// #ifdef H5
startH5Record()
// #endif
// #ifndef MP-WEIXIN
// #ifndef H5
sttHolding.value = false
notify('当前端暂不支持语音输入')
// #endif
// #endif
}
/** 松手结束:手指抬起 */
function endHoldSpeech() {
if (!sttHolding.value && !sttListening.value && !startRequested) {
return
}
sttHolding.value = false
if (sttListening.value || startRequested) {
stopListening()
return
}
holdSessionId += 1
}
// #ifdef MP-WEIXIN
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
wxRecordManager = plugin.getRecordRecognitionManager()
sttSupported.value = !!wxRecordManager
wxRecordManager.onStart = () => {
if (!sttHolding.value) {
try {
wxRecordManager.stop()
} catch (e) {}
return
}
startRequested = false
sttListening.value = true
}
wxRecordManager.onStop = (res) => {
sttListening.value = false
startRequested = false
emitResult(res?.result)
}
wxRecordManager.onError = (res) => {
sttListening.value = false
startRequested = false
sttHolding.value = false
notify(res?.msg || '语音识别失败')
}
} catch (e) {
console.warn('WechatSI record recognition not available', e)
}
// #endif
// #ifdef H5
try {
if (typeof window !== 'undefined') {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
if (SR) {
h5Recognition = new SR()
h5Recognition.lang = 'zh-CN'
h5Recognition.interimResults = false
h5Recognition.continuous = false
h5Recognition.maxAlternatives = 1
sttSupported.value = true
h5Recognition.onresult = (event) => {
const item = event.results?.[0]?.[0]
emitResult(item?.transcript || '')
}
h5Recognition.onend = () => {
sttListening.value = false
startRequested = false
sttHolding.value = false
}
h5Recognition.onerror = (event) => {
sttListening.value = false
startRequested = false
sttHolding.value = false
if (event?.error === 'not-allowed') {
notify('请允许浏览器使用麦克风')
} else if (event?.error !== 'aborted') {
notify('语音识别失败')
}
}
}
}
} catch (e) {
console.warn('H5 SpeechRecognition not available', e)
}
// #endif
onUnmounted(() => {
sttHolding.value = false
holdSessionId += 1
stopListening()
})
return {
sttListening,
sttHolding,
sttSupported,
beginHoldSpeech,
endHoldSpeech,
stopSpeechToText: endHoldSpeech
}
}
@@ -0,0 +1,115 @@
import { ref } from 'vue'
import { formatUserMessage } from '../utils/tongjiHelpers.js'
/** 登录 / 就诊卡门禁(index 与 more 共用) */
export function useTongjiAuth(proxy) {
const authChecking = ref(false)
let gateRedirected = false
let authSessionPromise = null
function hasAuthToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearAuthStorage() {
uni.removeStorageSync('token')
authSessionPromise = null
}
function wxLoginGetCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (res && res.code) resolve(res.code)
else reject(new Error('微信登录未返回 code'))
},
fail: reject
})
})
}
async function mnpLoginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res && res.code === 1 && res.data && res.data.token) {
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return res.data
}
clearAuthStorage()
throw new Error(formatUserMessage(res?.msg, '登录失败'))
}
async function doWxLogin() {
const code = await wxLoginGetCode()
await mnpLoginWithCode(code)
return hasAuthToken()
}
async function verifyOrLogin() {
if (!hasAuthToken()) {
return doWxLogin()
}
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res && res.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
} catch (e) {
/* 网络异常走重新登录 */
}
clearAuthStorage()
try {
return await doWxLogin()
} catch (e2) {
return false
}
}
async function ensureLoggedIn() {
if (authSessionPromise) {
return authSessionPromise
}
authSessionPromise = verifyOrLogin()
.then((ok) => {
if (!ok) authSessionPromise = null
return !!ok
})
.catch(() => {
authSessionPromise = null
return false
})
return authSessionPromise
}
function redirectToCardEntry(returnPath) {
if (gateRedirected) return
gateRedirected = true
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
uni.redirectTo({
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
})
}
function resetGateRedirect() {
gateRedirected = false
}
function isGateRedirected() {
return gateRedirected
}
return {
authChecking,
hasAuthToken,
ensureLoggedIn,
redirectToCardEntry,
resetGateRedirect,
isGateRedirected
}
}
+654
View File
@@ -0,0 +1,654 @@
/**
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
*/
import { svgToDataUrl } from '../utils/svgDataUrl.js'
function gid(key, suffix) {
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
}
function wrap(key, body) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
}
function shadow(key) {
const id = gid(key, 'sh')
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
}
function shine(x, y, rx, ry) {
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
}
/** 圆形水果 */
function artRound(key, base, dark, light, extra = '') {
const g = gid(key, 'g')
return wrap(key, `
<defs>
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
<stop offset="0%" stop-color="${light}"/>
<stop offset="55%" stop-color="${base}"/>
<stop offset="100%" stop-color="${dark}"/>
</radialGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
${shine(24, 28, 7, 5)}
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
${extra}
</g>`)
}
/** 叶菜 */
function artLeafy(key, light, mid, dark) {
const g1 = gid(key, 'g1')
const g2 = gid(key, 'g2')
return wrap(key, `
<defs>
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
</linearGradient>
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
${shine(26, 24, 6, 8)}
</g>`)
}
/** 西兰花 */
function artBroccoli(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs>
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
</radialGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
${shine(28, 20, 5, 4)}
</g>`)
}
/** 长条(黄瓜/香蕉/玉米) */
function artLong(key, base, dark, light, type = 'cucumber') {
const g = gid(key, 'g')
if (type === 'banana') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
${shine(30, 26, 5, 3)}
</g>`)
}
if (type === 'corn') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
Array.from({ length: 4 }, (_, c) =>
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
).join('')
).join('')}</g>
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
${shine(26, 26, 5, 8)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
</g>`)
}
/** 茄子 */
function artEggplant(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
${shine(26, 32, 5, 7)}
</g>`)
}
/** 番茄 */
function artTomato(key) {
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
}
/** 甜椒 */
function artPepper(key, color, dark) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
${shine(28, 26, 5, 6)}
</g>`)
}
/** 洋葱 */
function artOnion(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
${shine(24, 30, 6, 5)}
</g>`)
}
/** 葡萄串 */
function artGrapes(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
${shine(28, 24, 3, 2)}
</g>`)
}
/** 樱桃 */
function artCherry(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
</g>`)
}
/** 草莓 */
function artStrawberry(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
${shine(26, 28, 4, 6)}
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
).join('')}</g>
</g>`)
}
/** 豆类 */
function artBeans(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
</g>`)
}
/** 饮品杯 */
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
const g = gid(key, 'g')
if (type === 'bottle') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
${shine(28, 30, 4, 10)}
</g>`)
}
if (type === 'wine') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
${shine(26, 32, 4, 8)}
</g>`)
}
/** 蘑菇 */
function artMushroom(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
${shine(24, 24, 6, 4)}
</g>`)
}
/** 芹菜 */
function artCelery(key) {
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
}
/** 四季豆 */
function artGreenBean(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
</g>`)
}
/** 饭碗 */
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
${shine(24, 34, 6, 3)}
</g>`)
}
/** 薯类 */
function artRoot(key, base, dark, light) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
${shine(24, 30, 5, 6)}
</g>`)
}
/** 肉类 */
function artMeat(key, base, dark, type = 'steak') {
const g = gid(key, 'g')
if (type === 'chicken') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
${shine(26, 32, 5, 6)}
</g>`)
}
if (type === 'fish') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
${shine(28, 30, 6, 4)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
${shine(26, 28, 6, 4)}
</g>`)
}
/** 虾/蟹 */
function artSeafood(key, type = 'shrimp') {
const g = gid(key, 'g')
if (type === 'crab') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
${shine(30, 28, 5, 4)}
</g>`)
}
/** 菠萝 */
function artPineapple(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
).join('')}</g>
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
${shine(26, 32, 5, 6)}
</g>`)
}
/** 奇异果 */
function artKiwi(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
${shine(26, 28, 4, 3)}
</g>`)
}
/** 蜜瓜 */
function artMelon(key) {
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
}
/** 蜂蜜 */
function artHoney(key) {
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
}
/** 面包类 */
function artBread(key, base, dark, type = 'loaf') {
const g = gid(key, 'g')
if (type === 'donut') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
${shine(24, 28, 5, 4)}
</g>`)
}
if (type === 'mantou') {
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
${shine(24, 32, 6, 4)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
${shine(22, 28, 8, 5)}
</g>`)
}
/** 面条 */
function artNoodle(key, soup = '#d7ccc8') {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
</g>`)
}
/** 南瓜 */
function artPumpkin(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
${shine(26, 28, 5, 6)}
</g>`)
}
/** 薯条 */
function artFries(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
</g>`)
}
/** 西瓜 */
function artWatermelon(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
${shine(24, 30, 4, 3)}
</g>`)
}
/** 糖果 */
function artCandy(key, base, dark, type = 'lollipop') {
const g = gid(key, 'g')
if (type === 'wrap') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
</g>`)
}
return wrap(key, `
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
${shine(26, 24, 4, 3)}
</g>`)
}
/** 糯米团 */
function artDango(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="22" cy="36" r="8" fill="#fff"/>
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
<circle cx="42" cy="36" r="8" fill="#fff"/>
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
</g>`)
}
/** 爆米花 */
function artPopcorn(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
</g>`)
}
const FOOD_ART = {
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
broccoli: () => artBroccoli('broccoli'),
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
eggplant: () => artEggplant('eggplant'),
tomato: () => artTomato('tomato'),
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
onion: () => artOnion('onion'),
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
cherry: () => artCherry('cherry'),
grape: () => artGrapes('grape'),
strawberry: () => artStrawberry('strawberry'),
soybean: () => artBeans('soybean'),
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
mushroom: () => artMushroom('mushroom'),
celery: () => artCelery('celery'),
greenBean: () => artGreenBean('greenBean'),
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
shrimp: () => artSeafood('shrimp', 'shrimp'),
crab: () => artSeafood('crab', 'crab'),
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
pineapple: () => artPineapple('pineapple'),
kiwi: () => artKiwi('kiwi'),
melon: () => artMelon('melon'),
honey: () => artHoney('honey'),
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
udon: () => artNoodle('udon', '#d9b88f'),
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
popcorn: () => artPopcorn('popcorn'),
ramen: () => artNoodle('ramen', '#e0a96d'),
pumpkin: () => artPumpkin('pumpkin'),
bakedPotato: () => artFries('bakedPotato'),
watermelon: () => artWatermelon('watermelon'),
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
stickyRice: () => artDango('stickyRice')
}
const imgCache = {}
/** 获取食材插画 data URL(带缓存) */
export function getFoodImg(key) {
if (imgCache[key]) return imgCache[key]
const builder = FOOD_ART[key]
if (!builder) return ''
imgCache[key] = svgToDataUrl(builder())
return imgCache[key]
}
export function warmUpFoodImgs(keys) {
keys.forEach((k) => getFoodImg(k))
}
@@ -0,0 +1,49 @@
/**
* 食材图标 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
})
}
@@ -0,0 +1,175 @@
/**
* 全量食材库: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
@@ -0,0 +1,33 @@
/**
* 关卡配置:面向 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,28 @@
# VitalMint Health (Stitch)
Source: Google Stitch MCP · project `Modern WeChat UI Redesign`
Screen HTML: `design-system/stitch-weekly.html`
## Colors
- background / surface: `#f4fbf4`
- primary: `#006c49`
- primary-container: `#10b981`
- tertiary-container (餐后): `#fc7c78`
- on-surface: `#161d19`
- error (high glucose): `#ba1a1a`
- warning: `#ea580c`
## Layout (weekly.vue)
1. TopAppBar — 头像 + 问候 + 朗读按钮
2. 录入今日血糖 — 全宽圆角主按钮
3. 今日血糖 — 双列卡片 + 查看更多
4. 血糖趋势 — 单卡片内含统计、图表、7/30 切换
5. AI 饮食 — 渐变边框白底卡片
6. 能不能吃 — 独立卡片
## Styles
- `styles/stitch-vitalmint-theme.scss` — 设计 token
- `styles/weekly-stitch.scss` — 页面组件样式 (vm-*)
@@ -0,0 +1,398 @@
<!DOCTYPE html><html lang="zh-CN" style=""><head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>血糖管理</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-error-container": "#93000a",
"on-secondary-fixed-variant": "#3e4943",
"on-background": "#161d19",
"error-container": "#ffdad6",
"surface-container-highest": "#dde4dd",
"surface-container-high": "#e3eae3",
"primary-fixed-dim": "#4edea3",
"on-secondary-container": "#5b6760",
"on-primary": "#ffffff",
"inverse-surface": "#2b322d",
"tertiary-fixed-dim": "#ffb3af",
"primary": "#006c49",
"surface-container-lowest": "#ffffff",
"on-primary-fixed-variant": "#005236",
"on-surface-variant": "#3c4a42",
"surface-container": "#e8f0e9",
"tertiary-container": "#fc7c78",
"on-tertiary": "#ffffff",
"on-tertiary-container": "#711419",
"surface-container-low": "#eef6ee",
"on-secondary-fixed": "#131e19",
"inverse-primary": "#4edea3",
"surface-tint": "#006c49",
"secondary-fixed-dim": "#bdcac1",
"on-primary-container": "#00422b",
"on-secondary": "#ffffff",
"secondary-container": "#d9e6dd",
"tertiary": "#a43a3a",
"on-error": "#ffffff",
"secondary-fixed": "#d9e6dd",
"background": "#f4fbf4",
"tertiary-fixed": "#ffdad7",
"outline": "#6c7a71",
"primary-fixed": "#6ffbbe",
"on-tertiary-fixed": "#410005",
"outline-variant": "#bbcabf",
"primary-container": "#10b981",
"on-tertiary-fixed-variant": "#842225",
"surface-variant": "#dde4dd",
"on-primary-fixed": "#002113",
"on-surface": "#161d19",
"surface-bright": "#f4fbf4",
"inverse-on-surface": "#ebf3eb",
"surface": "#f4fbf4",
"surface-dim": "#d4dcd5",
"secondary": "#55615a",
"error": "#ba1a1a"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px",
"2xl": "1.5rem",
"3xl": "2rem"
},
"spacing": {
"container-margin": "20px",
"stack-gap": "16px",
"section-margin": "32px",
"inline-gap": "12px",
"card-padding": "20px"
},
"fontFamily": {
"body-lg": [
"Manrope"
],
"headline-lg": [
"Manrope"
],
"headline-md": [
"Manrope"
],
"display-lg": [
"Manrope"
],
"headline-lg-mobile": [
"Manrope"
],
"label-md": [
"Manrope"
],
"body-md": [
"Manrope"
]
},
"fontSize": {
"body-lg": [
"16px",
{
"lineHeight": "24px",
"fontWeight": "500"
}
],
"headline-lg": [
"24px",
{
"lineHeight": "32px",
"letterSpacing": "-0.01em",
"fontWeight": "700"
}
],
"headline-md": [
"20px",
{
"lineHeight": "28px",
"fontWeight": "700"
}
],
"display-lg": [
"32px",
{
"lineHeight": "40px",
"letterSpacing": "-0.02em",
"fontWeight": "800"
}
],
"headline-lg-mobile": [
"22px",
{
"lineHeight": "28px",
"fontWeight": "700"
}
],
"label-md": [
"12px",
{
"lineHeight": "16px",
"letterSpacing": "0.02em",
"fontWeight": "600"
}
],
"body-md": [
"14px",
{
"lineHeight": "20px",
"fontWeight": "400"
}
]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.icon-fill {
font-variation-settings: 'FILL' 1;
}
.ambient-shadow {
box-shadow: 0 12px 30px -10px rgba(0, 108, 73, 0.08), 0 4px 10px -4px rgba(0, 0, 0, 0.03);
}
.btn-press:active {
transform: scale(0.98);
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface font-body-lg antialiased pb-24 md:pb-0 min-h-screen">
<!-- Desktop Sidebar Shell (Hidden on Mobile) -->
<div class="hidden md:flex fixed left-0 top-0 h-full w-64 bg-surface-container-lowest border-r border-surface-dim z-50 flex-col">
<div class="p-6">
<h1 class="text-headline-lg font-headline-lg text-primary">VitalMint</h1>
</div>
<nav class="flex-1 px-4 flex flex-col gap-2 mt-4">
<a class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-container text-on-primary-container font-bold" href="#">
<span class="material-symbols-outlined icon-fill">home</span>
<span class="">首页</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
<span class="material-symbols-outlined">add_circle</span>
<span class="">记录</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
<span class="material-symbols-outlined">analytics</span>
<span class="">动态</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-on-surface-variant hover:bg-surface-container-high transition-colors" href="#">
<span class="material-symbols-outlined">person</span>
<span class="">我的</span>
</a>
</nav>
</div>
<!-- Main Content Area -->
<main class="md:ml-64 w-full max-w-[1200px] mx-auto min-h-screen flex flex-col md:flex-row">
<!-- Left/Main Column: Dashboard -->
<div class="flex-1 w-full">
<!-- TopAppBar -->
<header class="bg-surface dark:bg-surface-dim docked full-width top-0 flex justify-between items-center px-container-margin py-4 w-full sticky z-40">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary-container flex items-center justify-center overflow-hidden border-2 border-surface-container-lowest">
<span class="material-symbols-outlined text-primary">person</span>
</div>
<div>
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface dark:text-on-background">下午好,用户16295733</h1>
<p class="text-label-md font-label-md text-on-surface-variant opacity-80">今日血糖已记录</p>
</div>
</div>
<button class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container dark:hover:bg-surface-container-high transition-colors text-primary dark:text-primary-fixed-dim">
<span class="material-symbols-outlined" data-icon="volume_up">volume_up</span>
</button>
</header>
<!-- Content Canvas -->
<div class="px-container-margin pb-section-margin flex flex-col gap-6 mt-2">
<!-- 1. Primary Action (Prominent) -->
<section>
<button class="w-full bg-primary text-on-primary h-14 rounded-2xl flex items-center justify-center gap-2 text-body-lg font-bold btn-press shadow-[0_8px_16px_rgba(0,108,73,0.2)] transition-all">
<span class="material-symbols-outlined icon-fill">add</span>
录入今日血糖
</button>
</section>
<!-- 2. 血糖趋势 (Trends) - Moved to top -->
<section class="bg-surface-container-lowest rounded-3xl p-5 ambient-shadow border border-surface-container-highest/30">
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-headline-md font-headline-md text-on-surface">血糖趋势</h2>
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
</div>
<div class="flex items-center gap-3 text-label-md font-label-md text-on-surface-variant">
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary"></div>餐后</div>
</div>
</div>
<!-- Stats Row -->
<div class="flex justify-between items-center bg-surface-container-low rounded-xl p-4 mb-6">
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
<span class="text-headline-md font-headline-md text-tertiary">1</span>
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天偏高</span>
</div>
<div class="flex flex-col items-center flex-1 border-r border-surface-container-highest">
<span class="text-headline-md font-headline-md text-primary">1</span>
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天正常</span>
</div>
<div class="flex flex-col items-center flex-1">
<span class="text-headline-md font-headline-md text-on-surface">2</span>
<span class="text-label-md font-label-md text-on-surface-variant mt-1">天有记录</span>
</div>
</div>
<!-- Faux Chart Area -->
<div class="h-[180px] w-full relative flex items-end pt-4 pb-6 border-b border-surface-container-highest border-dashed">
<!-- Y Axis -->
<div class="absolute left-0 top-0 bottom-6 flex flex-col justify-between text-[10px] text-on-surface-variant">
<span class="">23.0</span>
<span class="">18.0</span>
<span class="">13.0</span>
<span class="">8.0</span>
<span class="">3.0</span>
</div>
<!-- Target Line -->
<div class="absolute left-6 right-0 bottom-12 border-b border-tertiary border-dashed opacity-30"></div>
<span class="absolute left-6 bottom-12 text-[10px] text-tertiary -translate-y-full mb-1">空腹阈 7</span>
<!-- Chart Lines -->
<div class="absolute inset-0 left-8 overflow-hidden">
<div class="absolute bottom-6 right-8 w-1 h-[100px] bg-primary/20 rounded-t-full transform rotate-45 origin-bottom"></div>
<div class="absolute bottom-[80px] right-2 w-1 h-[60px] bg-tertiary/20 rounded-t-full transform rotate-12 origin-bottom"></div>
<div class="absolute right-[15%] bottom-[15%] w-2 h-2 rounded-full border-[1.5px] border-primary bg-white z-10"></div>
<div class="absolute right-[5%] bottom-[75%] w-2 h-2 rounded-full border-[1.5px] border-tertiary bg-white z-10"></div>
<!-- Tooltips -->
<div class="absolute right-[2%] bottom-[85%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">18</div>
<div class="absolute right-[2%] bottom-[65%] bg-tertiary text-white text-[10px] px-1.5 py-0.5 rounded font-bold">20</div>
</div>
<!-- X Axis -->
<div class="absolute left-8 right-0 bottom-0 flex justify-between text-[10px] text-on-surface-variant translate-y-full pt-2">
<span class="">05-23</span>
<span class="">05-25</span>
<span class="">05-27</span>
<span class="">05-29</span>
</div>
</div>
</section>
<!-- 3. 最新测量 & 概览 (Combined Section) -->
<section class="flex flex-col gap-4">
<!-- Latest Reading -->
<div class="bg-error-container text-on-error-container rounded-3xl p-6 ambient-shadow relative overflow-hidden flex flex-col items-center text-center justify-center min-h-[160px]">
<div class="absolute inset-0 opacity-10 pointer-events-none" style="background-image: radial-gradient(circle at 2px 2px, currentColor 1px, transparent 0); background-size: 16px 16px;"></div>
<span class="text-label-md font-label-md uppercase tracking-wider mb-1 opacity-90 z-10">最新测量 · 餐后</span>
<div class="flex items-baseline gap-1 z-10">
<span class="text-[56px] font-extrabold leading-none tracking-tighter">20.00</span>
<span class="text-body-lg font-body-lg opacity-80 font-medium">mmol/L</span>
</div>
<div class="mt-3 inline-flex items-center gap-1.5 bg-white/30 backdrop-blur-sm px-3 py-1 rounded-full z-10">
<span class="material-symbols-outlined text-[16px] icon-fill">warning</span>
<span class="text-label-md font-label-md font-bold">偏高,请遵医嘱</span>
</div>
</div>
<!-- Today's Summary -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
<span class="text-label-md font-label-md text-on-surface-variant">空腹</span>
<div class="flex items-baseline gap-1 text-tertiary">
<span class="text-headline-lg font-headline-lg">18.00</span>
</div>
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
</div>
<div class="bg-surface-container-lowest rounded-2xl p-4 ambient-shadow flex flex-col gap-2 border border-surface-container-highest/50">
<span class="text-label-md font-label-md text-on-surface-variant">餐后</span>
<div class="flex items-baseline gap-1 text-tertiary">
<span class="text-headline-lg font-headline-lg">20.00</span>
</div>
<span class="text-[10px] text-tertiary bg-error-container/50 px-2 py-0.5 rounded-sm inline-block w-fit">偏高</span>
</div>
</div>
</section>
<!-- 4. AI 饮食建议 -->
<section class="bg-gradient-to-br from-primary/10 to-primary/5 rounded-3xl p-5 border border-primary/10 relative overflow-hidden">
<div class="absolute -right-8 -top-8 w-32 h-32 bg-primary-fixed-dim/20 rounded-full blur-2xl"></div>
<div class="flex justify-between items-center mb-4 relative z-10">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center">
<span class="material-symbols-outlined text-[18px]">temp_preferences_custom</span>
</div>
<h3 class="text-headline-md font-headline-md text-on-surface">AI 饮食建议</h3>
</div>
<button class="text-label-md font-label-md text-primary flex items-center gap-1 bg-white/50 px-3 py-1.5 rounded-full hover:bg-white transition-colors">
<span class="material-symbols-outlined text-[14px]">refresh</span>
换一换
</button>
</div>
<p class="text-body-md font-body-md text-on-surface-variant mb-4 relative z-10">读您近 7 天 · 30 天血糖,定制今日三餐</p>
<div class="flex flex-col gap-3 relative z-10">
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
<div class="flex items-center gap-2 mb-2">
<span class="material-symbols-outlined text-orange-400">light_mode</span>
<span class="font-bold text-on-surface">早餐</span>
</div>
<p class="text-body-md font-body-md text-on-surface-variant">小米粥(少米多水)加煮鸡蛋一个</p>
</div>
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-4 shadow-sm border border-white">
<div class="flex items-center gap-2 mb-2">
<span class="material-symbols-outlined text-primary">eco</span>
<span class="font-bold text-on-surface">午餐</span>
</div>
<p class="text-body-md font-body-md text-on-surface-variant">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
</div>
</div>
</section>
<!-- Spacer for bottom nav on mobile -->
<section class="sticky bottom-20 md:bottom-6 z-30 px-container-margin md:px-0 mb-4">
<div class="bg-white/90 backdrop-blur-md rounded-full p-2 pl-5 flex items-center gap-3 shadow-[0_8px_32px_rgba(0,108,73,0.15)] border border-primary/10">
<div class="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center">
<span class="material-symbols-outlined icon-fill text-[18px]">sparkles</span>
</div>
<input type="text" placeholder="输入食物名称或健康疑问,咨询 AI..." class="flex-1 bg-transparent border-none focus:ring-0 text-body-md placeholder:text-on-surface-variant/60 py-2">
<button class="bg-primary text-on-primary h-10 w-10 rounded-full flex items-center justify-center btn-press shadow-sm">
<span class="material-symbols-outlined">send</span>
</button>
</div>
</section><div class="h-8 md:hidden"></div>
</div>
</div>
<!-- Right Column: Secondary Info (Visible on Tablet/Desktop) -->
<aside class="hidden lg:flex w-80 flex-col gap-section-margin pt-4 pr-container-margin">
<div class="bg-surface-container-lowest rounded-3xl p-6 ambient-shadow">
<h3 class="text-headline-md font-headline-md text-on-surface mb-4">运动降糖</h3>
<div class="bg-error-container/30 rounded-xl p-4 mb-4">
<p class="text-body-md font-body-md text-on-surface">近期血糖偏高,三顿饭后都动一动,最能帮着把糖降下来。</p>
</div>
<ul class="flex flex-col gap-3 text-body-md text-on-surface-variant">
<li class="flex gap-2"><span class="font-bold text-tertiary">1</span> <span class="">早饭后快走 15 分钟</span></li>
<li class="flex gap-2"><span class="font-bold text-tertiary">2</span> <span class="">午饭后原地踏步 20-30 分钟</span></li>
</ul>
</div>
</aside>
</main>
<!-- BottomNavBar (Mobile Only) -->
</body></html>
@@ -0,0 +1,596 @@
<!DOCTYPE html><html class="light" lang="zh-CN"><head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Daily Care - Health Dashboard</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet">
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed": "#d9e6dd",
"surface-container": "#e8f0e9",
"surface-dim": "#d4dcd5",
"surface-variant": "#dde4dd",
"error-container": "#ffdad6",
"inverse-surface": "#2b322d",
"secondary": "#55615a",
"primary": "#006c49",
"on-secondary-fixed": "#131e19",
"surface-bright": "#f4fbf4",
"on-tertiary-container": "#711419",
"on-primary-fixed-variant": "#005236",
"outline-variant": "#bbcabf",
"primary-container": "#10b981",
"surface": "#f4fbf4",
"on-tertiary-fixed-variant": "#842225",
"tertiary-fixed-dim": "#ffb3af",
"tertiary-fixed": "#ffdad7",
"error": "#ba1a1a",
"surface-container-lowest": "#ffffff",
"surface-container-highest": "#dde4dd",
"primary-fixed": "#6ffbbe",
"on-primary-container": "#00422b",
"secondary-fixed-dim": "#bdcac1",
"on-tertiary": "#ffffff",
"on-primary-fixed": "#002113",
"on-error-container": "#93000a",
"tertiary-container": "#fc7c78",
"on-error": "#ffffff",
"inverse-primary": "#4edea3",
"inverse-on-surface": "#ebf3eb",
"on-secondary-container": "#5b6760",
"surface-container-high": "#e3eae3",
"surface-container-low": "#eef6ee",
"on-secondary-fixed-variant": "#3e4943",
"outline": "#6c7a71",
"secondary-container": "#d9e6dd",
"on-primary": "#ffffff",
"on-surface": "#161d19",
"on-background": "#161d19",
"tertiary": "#a43a3a",
"primary-fixed-dim": "#4edea3",
"on-secondary": "#ffffff",
"background": "#f4fbf4",
"on-surface-variant": "#3c4a42",
"surface-tint": "#006c49",
"on-tertiary-fixed": "#410005"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"2xl": "1rem",
"3xl": "1.5rem",
"full": "9999px"
},
"spacing": {
"card-padding": "20px",
"section-margin": "32px",
"inline-gap": "12px",
"stack-gap": "16px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": ["Manrope"],
"headline-lg-mobile": ["Manrope"],
"body-lg": ["Manrope"],
"headline-lg": ["Manrope"],
"display-lg": ["Manrope"],
"body-md": ["Manrope"],
"label-md": ["Manrope"]
},
"fontSize": {
"headline-md": ["20px", {"lineHeight": "28px", "fontWeight": "700"}],
"headline-lg-mobile": ["22px", {"lineHeight": "28px", "fontWeight": "700"}],
"body-lg": ["16px", {"lineHeight": "24px", "fontWeight": "500"}],
"headline-lg": ["24px", {"lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "700"}],
"display-lg": ["32px", {"lineHeight": "40px", "letterSpacing": "-0.02em", "fontWeight": "800"}],
"body-md": ["14px", {"lineHeight": "20px", "fontWeight": "400"}],
"label-md": ["12px", {"lineHeight": "16px", "letterSpacing": "0.02em", "fontWeight": "600"}]
}
}
}
}
</script>
<style>
body { font-family: 'Manrope', sans-serif; -webkit-tap-highlight-color: transparent; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; display: inline-block; vertical-align: middle; }
.ambient-shadow { box-shadow: 0 20px 30px -10px rgba(0, 108, 73, 0.08); }
.active-scale:active { transform: scale(0.98); transition: all 0.2s ease; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Watering Animation Styles */
@keyframes fall {
0% { transform: translateY(-20px); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(40px); opacity: 0; }
}
.droplet {
position: absolute;
width: 4px;
height: 10px;
background: #3b82f6;
border-radius: 50%;
animation: fall 0.6s linear infinite;
}
@keyframes pulse-growth {
0% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
50% { transform: scale(1.15); filter: drop-shadow(0 0 15px rgba(78, 222, 163, 0.6)); }
100% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(78, 222, 163, 0)); }
}
.growth-pulse {
animation: pulse-growth 0.8s ease-out;
}
@keyframes shimmer {
0% { opacity: 0; transform: scale(0) rotate(0deg); }
50% { opacity: 1; transform: scale(1.2) rotate(180deg); }
100% { opacity: 0; transform: scale(0) rotate(360deg); }
}
.shimmer-particle {
position: absolute;
color: #4edea3;
font-size: 14px;
pointer-events: none;
animation: shimmer 1s ease-out forwards;
}
/* Blood Sugar Decrease Animation */
@keyframes float-up-fade {
0% { transform: translateY(20px); opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { transform: translateY(-100px); opacity: 0; }
}
.sugar-decrease-text {
position: fixed;
color: #10b981;
font-weight: 800;
font-size: 24px;
pointer-events: none;
z-index: 100;
text-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
animation: float-up-fade 2s ease-out forwards;
}
@keyframes fall-arrow {
0% { transform: translateY(-50vh) scale(0.5); opacity: 0; }
50% { opacity: 0.8; }
100% { transform: translateY(110vh) scale(1.2); opacity: 0; }
}
.sugar-particle {
position: fixed;
color: #10b981;
pointer-events: none;
z-index: 90;
animation: fall-arrow 1.5s linear forwards;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-background min-h-screen pb-24">
<!-- Top App Bar -->
<header class="bg-primary docked full-width top-0 h-48 flex items-end pb-6 px-5 fixed left-0 right-0 z-40">
<div class="flex justify-between items-center w-full">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full border-2 border-on-primary overflow-hidden">
<img class="w-full h-full object-cover" data-alt="A professional studio portrait" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCXIPbeivladmw0-SmLno2vkwVvsObW2D5HUtodItlBnxdhFF8TvZdQYrTt14QCk3n5VXXIpdk3cuWTJHXJgDXd_clQyjLsb477e5h2313zh76yRVns01Q5pLpM-2_RbheRcUioLvObwJtIPJbkDcbkNA61-LyBnbStRcPoh6YOvNHSHjUUR_nDJpPm18ga87ggphbFWF-pt7lRF027swmyitUeVF7HXQDfcG7Mq7E0pqsbhyA7jKxtzmbN-HLh7GIrhc6LOAUaXiX9">
</div>
<div>
<h1 class="text-on-primary font-bold text-headline-lg-mobile leading-tight">Daily Care</h1>
<p class="text-on-primary/80 text-body-md">吕帅</p>
</div>
</div>
<div class="flex gap-2">
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary rounded-full px-4 py-1.5 flex items-center gap-2 active-scale transition-all">
<span class="material-symbols-outlined text-[18px]" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
<span class="text-label-md">血糖</span>
</button>
<button class="bg-on-primary/10 hover:bg-on-primary/20 text-on-primary w-10 h-10 rounded-full flex items-center justify-center active-scale transition-all">
<span class="material-symbols-outlined text-[20px]">refresh</span>
</button>
</div>
</div>
</header>
<main class="pt-52 px-container-margin space-y-6">
<!-- Toggle Switch -->
<div class="bg-surface-container-high rounded-full p-1 flex items-center">
<button class="flex-1 bg-primary text-on-primary rounded-full py-2 text-label-md font-bold shadow-md transition-all">最近 7 天</button>
<button class="flex-1 text-on-surface-variant py-2 text-label-md font-bold transition-all">最近 30 天</button>
</div>
<!-- Health Calendar -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-start mb-4">
<div>
<h2 class="text-headline-md font-headline-md text-on-surface">健康日历</h2>
<p class="text-body-md text-outline">2026年5月 · 已记录 3 / 7 天</p>
</div>
<button class="text-primary"><span class="material-symbols-outlined">chevron_right</span></button>
</div>
<div class="grid grid-cols-7 gap-1 text-center mb-4">
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-outline"></span>
<span class="text-label-md text-tertiary"></span>
<span class="py-2 text-body-md text-outline/30">18</span>
<span class="py-2 text-body-md text-outline/30">19</span>
<span class="py-2 text-body-md text-outline/30">20</span>
<span class="py-2 text-body-md text-outline/30">21</span>
<span class="py-2 text-body-md text-outline/30">22</span>
<div class="py-2 relative"><span class="text-body-md font-bold text-on-surface">23</span></div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">24</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">25</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<div class="py-2 relative bg-primary-container/10 rounded-lg">
<span class="text-body-md font-bold text-primary">26</span>
<span class="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full"></span>
</div>
<span class="py-2 text-body-md text-on-surface">27</span>
<span class="py-2 text-body-md text-on-surface">28</span>
<div class="py-2 relative bg-tertiary-container rounded-lg">
<span class="text-body-md font-bold text-on-tertiary">29</span>
</div>
<span class="py-2 text-body-md text-outline/40">30</span>
<span class="py-2 text-body-md text-outline/40">31</span>
</div>
<div class="flex items-center justify-between border-t border-outline-variant/30 pt-4 mt-2">
<span class="text-body-md text-outline">今日还没有记录</span>
<button class="bg-primary text-on-primary px-6 py-3 rounded-2xl text-label-md font-bold active-scale transition-all flex items-center gap-2">去记血糖</button>
</div>
</section>
<!-- Blood Sugar Details Section -->
<section class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-headline-md font-headline-md text-on-surface">血糖明细</h3>
<span class="text-body-md text-outline">共 3 天</span>
</div>
<div class="space-y-4">
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-center mb-4">
<span class="text-headline-md font-bold">05-26</span>
<span class="text-body-md text-outline">周二</span>
</div>
<div class="space-y-2">
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">空腹</span>
<div class="text-right">
<span class="text-headline-lg font-extrabold text-error">10.4</span>
<span class="text-label-md text-outline ml-1">mmol/L</span>
</div>
</div>
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">餐后</span>
<span class="text-headline-md font-bold text-outline-variant">—— <small class="text-label-md">mmol/L</small></span>
</div>
</div>
</div>
<div class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow">
<div class="flex justify-between items-center mb-4">
<span class="text-headline-md font-bold">05-25</span>
<span class="text-body-md text-outline">周一</span>
</div>
<div class="bg-background/50 border border-outline-variant/20 rounded-2xl p-4 flex justify-between items-center">
<span class="text-body-md text-outline">空腹</span>
<div class="text-right">
<span class="text-headline-lg font-extrabold text-error">10.2</span>
<span class="text-label-md text-outline ml-1">mmol/L</span>
</div>
</div>
</div>
</div>
</section>
<!-- Reward Section: 稳糖乐园 -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow overflow-hidden relative" id="sugar-paradise">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-headline-md font-headline-md text-on-surface">稳糖乐园</h3>
<p class="text-body-md text-outline">用健康打卡浇灌你的稳糖树</p>
</div>
<div class="bg-primary/10 rounded-xl px-3 py-1 text-center">
<span class="block text-headline-lg font-extrabold text-primary" id="total-points">110</span>
<span class="text-[10px] text-primary/70 font-bold">稳糖积分</span>
</div>
</div>
<!-- Mini Quick Actions -->
<div class="grid grid-cols-4 gap-2 mb-8">
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">bloodtype</span>
<span class="text-label-md text-on-surface">血糖</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">monitoring</span>
<span class="text-label-md text-on-surface">血压</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">restaurant</span>
<span class="text-label-md text-on-surface">饮食</span>
</button>
<button class="bg-surface-container-low p-3 rounded-2xl flex flex-col items-center gap-1 active-scale transition-all">
<span class="material-symbols-outlined text-primary">exercise</span>
<span class="text-label-md text-on-surface">运动</span>
</button>
</div>
<h4 class="text-label-md font-bold text-on-surface mb-3">今日控糖任务</h4>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-primary flex items-center justify-center">
<span class="material-symbols-outlined text-[16px] text-primary" style="font-variation-settings: 'wght' 700;">check</span>
</div>
<span class="text-body-md text-on-surface">测血糖血压</span>
</div>
<div class="flex items-center gap-3">
<span class="text-label-md text-primary font-bold">+10积分</span>
<span class="text-outline text-label-md font-bold italic">已完成</span>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-outline-variant/30 flex items-center justify-center">
<span class="w-2 h-2 rounded-full bg-outline-variant"></span>
</div>
<span class="text-body-md text-on-surface">记今日饮食</span>
</div>
<div class="flex items-center gap-3">
<span class="text-label-md text-primary font-bold">+10积分</span>
<button class="bg-on-surface text-surface rounded-full px-4 py-1 text-label-md font-bold active-scale transition-all">去记录</button>
</div>
</div>
</div>
<!-- Optimized Level System & Gamification -->
<div class="mt-8 bg-gradient-to-br from-primary/5 to-primary/20 rounded-3xl p-6 border border-primary/10 relative overflow-hidden">
<!-- Level Indicator Header -->
<div class="flex justify-between items-end mb-6">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="bg-primary text-on-primary text-[10px] px-2 py-0.5 rounded-full font-extrabold" id="level-label">Lv.4</span>
<span class="text-body-lg font-extrabold text-primary" id="stage-name">枝繁叶茂</span>
</div>
<p class="text-[11px] text-primary/60">还差 <span class="font-bold">15 XP</span> 升级至 Lv.4</p>
</div>
<div class="text-right">
<span class="text-label-md font-bold text-primary" id="xp-text">5/50 XP</span>
</div>
</div>
<!-- Progress Bar -->
<div class="w-full h-2.5 bg-white/50 rounded-full mb-8 overflow-hidden backdrop-blur-sm border border-white/20">
<div class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full transition-all duration-500 shadow-inner" id="xp-progress" style="width: 10%;"></div>
</div>
<!-- Plant Growth Display -->
<div class="flex items-center justify-center py-8 relative min-h-[160px]">
<!-- Watering Animation Container -->
<div class="absolute top-0 left-0 w-full h-full pointer-events-none z-10" id="rain-container"></div>
<!-- Visual Stage Indicator Background -->
<div class="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
<span class="text-[120px] material-symbols-outlined text-primary" style="font-variation-settings: 'FILL' 1;">eco</span>
</div>
<!-- Current Plant State -->
<div class="relative z-20 transition-all duration-300" id="plant-container">
<span class="text-7xl drop-shadow-2xl filter block" id="plant-emoji">🪴</span>
</div>
</div>
<!-- Watering Action -->
<div class="relative z-30">
<button class="w-full bg-primary text-on-primary py-4 rounded-2xl text-body-lg font-bold flex items-center justify-center gap-3 shadow-lg active:scale-95 transition-all active:bg-primary-container group" id="water-btn">
<span class="material-symbols-outlined text-[24px] transition-transform group-active:-rotate-12 group-hover:scale-110">opacity</span>
给小树浇水
</button>
<p class="text-[11px] text-center text-outline mt-3 italic">浇水可消耗 5 积分,获得 10 XP</p>
</div>
</div>
</section>
<!-- Social Interaction -->
<section class="bg-surface-container-lowest rounded-3xl p-card-padding ambient-shadow flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-tertiary-container/20 rounded-2xl flex items-center justify-center">
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">favorite</span>
</div>
<div>
<h4 class="text-body-md font-bold text-on-surface">邀请家人点赞</h4>
<p class="text-[12px] text-outline">分享战报给家人,邀请他们为您点赞鼓励</p>
</div>
</div>
<span class="material-symbols-outlined text-outline">chevron_right</span>
</section>
<!-- Status Tip -->
<div class="flex items-start gap-3 px-2">
<span class="material-symbols-outlined text-primary-container text-[20px]">chat_bubble</span>
<div>
<p class="text-body-md text-on-surface">先完成血糖记录,<span class="text-primary font-bold">今日第一格就亮起来</span></p>
<button class="text-outline text-label-md hover:text-primary transition-colors">轻触换一句鼓励 </button>
</div>
</div>
<!-- Care List Horizontal -->
<div class="flex items-center gap-3 overflow-x-auto hide-scrollbar -mx-container-margin px-container-margin pb-2">
<div class="shrink-0 flex items-center gap-2 bg-white px-3 py-2 rounded-xl shadow-sm border border-outline-variant/20">
<span class="text-label-md text-outline">就诊卡</span>
<span class="text-label-md font-extrabold text-on-surface">10 张</span>
</div>
<div class="shrink-0 flex items-center gap-2 bg-primary text-on-primary px-3 py-2 rounded-xl shadow-md">
<span class="text-label-md font-bold">吕帅</span>
<span class="text-[10px] bg-white/20 rounded px-1">23岁</span>
</div>
<div class="shrink-0 flex items-center gap-2 bg-surface-container-high text-on-surface-variant px-3 py-2 rounded-xl border border-outline-variant/30">
<span class="text-label-md font-bold">霍爱玲</span>
<span class="text-[10px] text-outline">57岁</span>
</div>
</div>
</main>
<!-- Footer Note -->
<footer class="mt-8 text-center px-container-margin pb-12">
<p class="text-[11px] text-outline-variant/60">数据每日同步 · 阈值仅作参考,请遵医嘱</p>
</footer>
<!-- Navigation Bar -->
<script>
// State Management
let currentXP = 35;
let currentLevel = 3;
let totalPoints = 120;
const maxLevel = 10;
const xpPerLevel = 50;
const stageNames = [
"种子眠", "破土而出", "茁壮成长", "枝繁叶茂", "初绽花蕾",
"繁花似锦", "硕果初步", "丰收在望", "参天大树", "森林守护"
];
const plantEmojis = [
"🫘", "🌱", "🌿", "🪴", "🎍",
"🌸", "🍒", "🍎", "🌳", "🌲"
];
// DOM Elements
const waterBtn = document.getElementById('water-btn');
const rainContainer = document.getElementById('rain-container');
const plantContainer = document.getElementById('plant-container');
const plantEmoji = document.getElementById('plant-emoji');
const xpProgress = document.getElementById('xp-progress');
const xpText = document.getElementById('xp-text');
const levelLabel = document.getElementById('level-label');
const stageNameLabel = document.getElementById('stage-name');
const totalPointsLabel = document.getElementById('total-points');
// Watering Animation Logic
function createRain() {
for (let i = 0; i < 15; i++) {
const droplet = document.createElement('div');
droplet.className = 'droplet';
droplet.style.left = `${Math.random() * 100}%`;
droplet.style.animationDelay = `${Math.random() * 0.4}s`;
rainContainer.appendChild(droplet);
// Cleanup
setTimeout(() => droplet.remove(), 600);
}
}
function triggerGrowthPulse() {
plantContainer.classList.add('growth-pulse');
setTimeout(() => {
plantContainer.classList.remove('growth-pulse');
}, 800);
}
function createSparkles() {
for (let i = 0; i < 12; i++) {
const sparkle = document.createElement('span');
sparkle.className = 'material-symbols-outlined shimmer-particle';
sparkle.textContent = 'sparkles';
sparkle.style.left = `${40 + (Math.random() - 0.5) * 40}%`;
sparkle.style.top = `${40 + (Math.random() - 0.5) * 40}%`;
sparkle.style.animationDuration = `${0.6 + Math.random() * 0.4}s`;
plantContainer.appendChild(sparkle);
setTimeout(() => sparkle.remove(), 1000);
}
}
function triggerSugarDecreaseEffect() {
// 1. Floating Text
const floatingText = document.createElement('div');
floatingText.className = 'sugar-decrease-text';
floatingText.textContent = '-0.5 mmol/L';
// Randomize spawn position near the plant
const rect = plantContainer.getBoundingClientRect();
floatingText.style.left = `${rect.left + rect.width / 2}px`;
floatingText.style.top = `${rect.top}px`;
document.body.appendChild(floatingText);
setTimeout(() => floatingText.remove(), 2000);
// 2. Full-screen Downward Particles
for (let i = 0; i < 20; i++) {
setTimeout(() => {
const particle = document.createElement('div');
particle.className = 'sugar-particle text-primary-container';
// Mix arrows and symbols
particle.innerHTML = Math.random() > 0.5 ? '<span class="material-symbols-outlined">south</span>' : '▼';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.fontSize = `${16 + Math.random() * 16}px`;
particle.style.opacity = '0';
document.body.appendChild(particle);
setTimeout(() => particle.remove(), 1500);
}, i * 50);
}
}
function updateDisplay() {
const progress = (currentXP / xpPerLevel) * 100;
xpProgress.style.width = `${progress}%`;
xpText.textContent = `${currentXP}/${xpPerLevel} XP`;
levelLabel.textContent = `Lv.${currentLevel}`;
stageNameLabel.textContent = stageNames[currentLevel - 1];
plantEmoji.textContent = plantEmojis[currentLevel - 1];
totalPointsLabel.textContent = totalPoints;
}
// Action Logic
waterBtn.addEventListener('click', () => {
if (totalPoints < 5) {
alert("积分不足,快去完成任务领积分吧!");
return;
}
// Lock button briefly
waterBtn.disabled = true;
waterBtn.classList.add('opacity-50');
// 1. Consume Points
totalPoints -= 5;
// 2. Trigger Animations
createRain();
setTimeout(() => {
triggerGrowthPulse();
createSparkles();
triggerSugarDecreaseEffect(); // New Effect
// 3. Update State
currentXP += 10;
if (currentXP >= xpPerLevel && currentLevel < maxLevel) {
currentXP -= xpPerLevel;
currentLevel++;
}
updateDisplay();
// Unlock button
waterBtn.disabled = false;
waterBtn.classList.remove('opacity-50');
}, 600);
});
// Initialize
updateDisplay();
// Simple button micro-interactions for others
document.querySelectorAll('button:not(#water-btn)').forEach(btn => {
btn.addEventListener('click', () => {
if(!btn.classList.contains('cursor-not-allowed')) {
// Interaction logic
}
});
});
</script>
</body></html>
@@ -0,0 +1 @@
{"name":"projects/15362588878567114380/screens/259c8c233434481881d0bacddb59fa7f","title":"血糖管理重构版","screenshot":{"name":"projects/15362588878567114380/files/c18dd3e3d7774a16897d78b40073b9f2","downloadUrl":"https://lh3.googleusercontent.com/aida/ADBb0ugpswBosy3eCG_j3kJlxCWpntXQGvRJvMuepINJTcB47Nf4ymWU81mrjex1ICPdAXn-p_JjyUokOtms4zIJpXL49692zt16mIJhEC16nfU9kH5hwAj1s7v3PlAZbUGWjYjPTdfVMaOCveS6Ohuh_IeyRTD6MmdsFZ2-H7Whg6zy4MiZeGLxRw0ckCOrNbTRObtuzk_dlUc49eDGeRagYtuL9xyS6pno5CiwBzSkGp872chdZVn9JOK-2n1P"},"htmlCode":{"name":"projects/15362588878567114380/files/ca56c13fc2d6481d9de60727f7b70f3f","downloadUrl":"https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ8Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpbCiVodG1sXzk5NTVmZjZjNTFkODQxMmY4MzIxNWNkYmIwNWI2MTMzEgsSBxCEnv3unRAYAZIBJAoKcHJvamVjdF9pZBIWQhQxNTM2MjU4ODg3ODU2NzExNDM4MA&filename=&opi=89354086","mimeType":"text/html"},"width":"780","height":"4122","deviceType":"MOBILE"}
@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html class="light" lang="zh-CN"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>血糖管理</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"on-tertiary-fixed": "#410005",
"surface-bright": "#f4fbf4",
"on-primary-container": "#00422b",
"surface-container": "#e8f0e9",
"surface-container-high": "#e3eae3",
"surface-dim": "#d4dcd5",
"primary-container": "#10b981",
"on-secondary-fixed": "#131e19",
"surface": "#f4fbf4",
"on-surface": "#161d19",
"on-tertiary": "#ffffff",
"tertiary-container": "#fc7c78",
"secondary": "#55615a",
"on-error-container": "#93000a",
"on-background": "#161d19",
"secondary-fixed-dim": "#bdcac1",
"inverse-on-surface": "#ebf3eb",
"surface-container-highest": "#dde4dd",
"on-error": "#ffffff",
"on-surface-variant": "#3c4a42",
"secondary-fixed": "#d9e6dd",
"inverse-surface": "#2b322d",
"on-secondary": "#ffffff",
"on-tertiary-fixed-variant": "#842225",
"outline-variant": "#bbcabf",
"on-primary-fixed-variant": "#005236",
"on-secondary-container": "#5b6760",
"on-primary": "#ffffff",
"primary": "#006c49",
"outline": "#6c7a71",
"tertiary-fixed": "#ffdad7",
"error": "#ba1a1a",
"tertiary": "#a43a3a",
"surface-tint": "#006c49",
"inverse-primary": "#4edea3",
"on-tertiary-container": "#711419",
"primary-fixed-dim": "#4edea3",
"on-secondary-fixed-variant": "#3e4943",
"tertiary-fixed-dim": "#ffb3af",
"surface-container-low": "#eef6ee",
"error-container": "#ffdad6",
"on-primary-fixed": "#002113",
"background": "#f4fbf4",
"surface-container-lowest": "#ffffff",
"primary-fixed": "#6ffbbe",
"surface-variant": "#dde4dd",
"secondary-container": "#d9e6dd"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px",
"2xl": "1.5rem"
},
"spacing": {
"section-margin": "32px",
"stack-gap": "16px",
"card-padding": "20px",
"inline-gap": "12px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": ["Manrope"],
"label-md": ["Manrope"],
"body-lg": ["Manrope"],
"headline-lg-mobile": ["Manrope"],
"headline-lg": ["Manrope"],
"display-lg": ["Manrope"],
"body-md": ["Manrope"]
},
"boxShadow": {
'ambient': '0 10px 30px -10px rgba(0, 108, 73, 0.08)',
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.material-symbols-outlined.fill-icon {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-surface text-on-surface font-body-md antialiased md:max-w-[600px] md:mx-auto pb-24">
<!-- TopAppBar -->
<header class="flex justify-between items-center px-container-margin py-4 w-full bg-surface top-0 sticky z-40">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden">
<span class="material-symbols-outlined text-outline">person</span>
</div>
<div>
<h1 class="text-headline-lg-mobile font-headline-lg-mobile text-on-surface">下午好,用户16295733</h1>
<p class="text-label-md font-label-md text-on-surface-variant">今日血糖已记录</p>
</div>
</div>
<button class="flex flex-col items-center justify-center bg-surface-container-low border border-outline-variant rounded-xl px-3 py-1.5 hover:bg-surface-container transition-colors">
<span class="material-symbols-outlined text-primary mb-0.5">volume_up</span>
<span class="text-[10px] font-label-md text-on-surface-variant">朗读血糖</span>
</button>
</header>
<main class="px-container-margin flex flex-col gap-section-margin mt-4">
<!-- Primary Action -->
<section>
<button class="w-full bg-primary text-on-primary rounded-full py-4 flex items-center justify-center gap-2 hover:bg-on-primary-fixed-variant transition-colors shadow-ambient active:scale-95 duration-150">
<span class="material-symbols-outlined">add</span>
<span class="text-body-lg font-body-lg font-bold">录入今日血糖</span>
</button>
</section>
<!-- Current Status Cards -->
<section class="flex flex-col gap-3">
<h2 class="text-headline-md font-headline-md">今日血糖</h2>
<div class="grid grid-cols-2 gap-3">
<!-- Fasting -->
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
<div class="text-label-md font-label-md text-on-surface-variant mb-1">空腹</div>
<div class="flex items-baseline gap-1 mb-2">
<span class="text-display-lg font-display-lg text-error">18.00</span>
<span class="text-label-md font-label-md text-error">mmol/L</span>
</div>
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
<span class="material-symbols-outlined text-[14px]">warning</span>
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
</div>
</div>
<!-- Post-meal -->
<div class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient border border-error-container">
<div class="text-label-md font-label-md text-on-surface-variant mb-1">餐后</div>
<div class="flex items-baseline gap-1 mb-2">
<span class="text-display-lg font-display-lg text-error">20.00</span>
<span class="text-label-md font-label-md text-error">mmol/L</span>
</div>
<div class="flex items-center gap-1 text-error bg-error-container/30 px-2 py-1 rounded-md w-fit">
<span class="material-symbols-outlined text-[14px]">warning</span>
<span class="text-[10px] font-label-md">偏高,请遵医嘱</span>
</div>
</div>
</div>
<button class="w-full flex items-center justify-between bg-surface-container-lowest rounded-xl p-4 shadow-ambient mt-2">
<div>
<div class="text-body-md font-body-md font-bold">查看更多</div>
<div class="text-label-md font-label-md text-on-surface-variant">健康日历 · 运动 · 家人互动</div>
</div>
<span class="material-symbols-outlined text-outline">chevron_right</span>
</button>
</section>
<!-- Data Overview: Trend -->
<section class="bg-surface-container-lowest rounded-2xl p-card-padding shadow-ambient">
<div class="flex justify-between items-end mb-4">
<div>
<h2 class="text-headline-md font-headline-md">血糖趋势</h2>
<p class="text-label-md font-label-md text-on-surface-variant">最近 7 天 · mmol/L</p>
</div>
<div class="flex gap-3 text-label-md font-label-md">
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-primary"></div>空腹</div>
<div class="flex items-center gap-1"><div class="w-2 h-2 rounded-full bg-tertiary-container"></div>餐后</div>
</div>
</div>
<!-- Summary Stats -->
<div class="flex bg-surface-container-low rounded-xl p-3 mb-6 divide-x divide-outline-variant">
<div class="flex-1 flex flex-col items-center">
<span class="text-headline-md font-headline-md text-error">1</span>
<span class="text-label-md font-label-md text-on-surface-variant">天偏高</span>
</div>
<div class="flex-1 flex flex-col items-center">
<span class="text-headline-md font-headline-md text-primary">1</span>
<span class="text-label-md font-label-md text-on-surface-variant">天正常</span>
</div>
<div class="flex-1 flex flex-col items-center">
<span class="text-headline-md font-headline-md text-on-surface">2</span>
<span class="text-label-md font-label-md text-on-surface-variant">天有记录</span>
</div>
</div>
<!-- Chart Area Placeholder -->
<div class="relative h-48 w-full mb-6 border-b border-l border-outline-variant">
<!-- Y Axis Labels -->
<div class="absolute -left-6 top-0 bottom-0 flex flex-col justify-between text-[10px] text-on-surface-variant">
<span>23.0</span>
<span>18.0</span>
<span>13.0</span>
<span>8.0</span>
<span>3.0</span>
</div>
<!-- X Axis Labels -->
<div class="absolute -bottom-5 left-0 right-0 flex justify-between text-[10px] text-on-surface-variant">
<span>05-23</span>
<span>05-25</span>
<span>05-27</span>
<span>05-29</span>
</div>
<!-- Threshold Line -->
<div class="absolute bottom-[25%] w-full border-t border-dashed border-error opacity-50">
<span class="absolute -top-4 text-[10px] text-error">空腹阈值 7</span>
</div>
<!-- Abstract Chart representation -->
<svg class="absolute inset-0 h-full w-full" preserveaspectratio="none" viewbox="0 0 100 100">
<path d="M 80 80 L 95 30" fill="none" stroke="#006c49" stroke-width="2"></path>
<path d="M 85 85 L 95 15" fill="none" stroke="#fc7c78" stroke-width="2"></path>
<circle cx="80" cy="80" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
<circle cx="95" cy="30" fill="#ffffff" r="2" stroke="#006c49" stroke-width="1.5"></circle>
<circle cx="85" cy="85" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
<circle cx="95" cy="15" fill="#ffffff" r="2" stroke="#fc7c78" stroke-width="1.5"></circle>
</svg>
</div>
<!-- Time Toggles -->
<div class="flex bg-surface-container-low rounded-full p-1 w-full max-w-[240px] mx-auto mt-8">
<button class="flex-1 py-2 text-label-md font-label-md bg-primary text-on-primary rounded-full shadow-sm">最近 7 天</button>
<button class="flex-1 py-2 text-label-md font-label-md text-on-surface-variant rounded-full">最近 30 天</button>
</div>
</section>
<!-- AI Nutrition Section -->
<section class="bg-gradient-to-br from-primary-container/20 to-primary/10 rounded-2xl p-[2px] shadow-ambient overflow-hidden relative">
<div class="bg-surface-container-lowest rounded-2xl p-card-padding relative z-10">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<span class="material-symbols-outlined text-on-primary text-[18px]">auto_awesome</span>
</div>
<div>
<div class="flex items-center gap-2">
<h3 class="text-body-lg font-body-lg font-bold">AI 饮食建议</h3>
<span class="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full border border-primary/20">智能定制</span>
</div>
<p class="text-[10px] text-on-surface-variant mt-0.5">读您近 7 天 · 30 天血糖,定制今日三餐</p>
</div>
</div>
<button class="flex items-center gap-1 text-label-md font-label-md text-primary bg-primary/10 px-3 py-1.5 rounded-full">
<span class="material-symbols-outlined text-[14px]">refresh</span>换一换
</button>
</div>
<!-- Meals List -->
<div class="flex flex-col gap-3 mb-4">
<div class="text-label-md font-label-md text-primary font-bold flex items-center gap-1">
<div class="w-1 h-3 bg-primary rounded-full"></div> 今日三餐
</div>
<!-- Breakfast -->
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="material-symbols-outlined text-tertiary-container text-[16px] fill-icon">wb_sunny</span>
<span class="text-label-md font-label-md font-bold">早餐</span>
</div>
<p class="text-body-md font-body-md">小米粥(少米多水)加煮鸡蛋一个</p>
</div>
<!-- Lunch -->
<div class="bg-primary/5 p-3 rounded-xl border-l-2 border-primary">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="material-symbols-outlined text-primary text-[16px] fill-icon">wb_twilight</span>
<span class="text-label-md font-label-md font-bold text-primary">午餐</span>
</div>
<p class="text-body-md font-body-md">清蒸鱼块、蒜蓉炒苋菜、二米饭(小米掺大米)</p>
</div>
<!-- Dinner -->
<div class="bg-surface p-3 rounded-xl border border-surface-container-highest">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="material-symbols-outlined text-[#7e85cc] text-[16px] fill-icon">dark_mode</span>
<span class="text-label-md font-label-md font-bold">晚餐</span>
</div>
<p class="text-body-md font-body-md">玉米糝粥(稀)配凉拌黄瓜</p>
</div>
</div>
<!-- Tips -->
<div class="bg-surface-container-low rounded-xl p-3 mb-4 text-body-md font-body-md text-on-surface-variant flex gap-2">
<span class="material-symbols-outlined text-primary text-[16px] mt-0.5">info</span>
<p>小贴士:先吃菜和肉再吃主食,每餐七分饱,血糖高就少喝粥汤。</p>
</div>
<!-- Avoid List -->
<div class="mb-6">
<div class="flex items-center gap-1 text-error text-label-md font-label-md mb-2">
<span class="material-symbols-outlined text-[14px]">warning</span> 今日尽量少碰
</div>
<div class="flex flex-wrap gap-2">
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">白馒头</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">油条</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">粘豆包</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">糯米饭</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">西瓜</span>
<span class="bg-error-container/30 text-error px-3 py-1 rounded-full text-[12px] border border-error-container">含糖饮料</span>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button class="flex-1 bg-primary text-on-primary rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-on-primary-fixed-variant transition-colors">填入今日饮食</button>
<button class="flex-1 bg-surface-container border border-outline-variant text-on-surface rounded-xl py-3 text-body-md font-body-md font-bold hover:bg-surface-container-high transition-colors">手动记录</button>
</div>
</div>
<!-- Decorative background elements -->
<div class="absolute top-0 right-0 w-32 h-32 bg-primary/10 rounded-full blur-2xl -mr-10 -mt-10 pointer-events-none"></div>
<div class="absolute bottom-0 left-0 w-24 h-24 bg-tertiary-container/10 rounded-full blur-xl -ml-10 -mb-10 pointer-events-none"></div>
</section>
<!-- Ask AI -->
<section class="bg-surface-container-lowest rounded-2xl p-4 shadow-ambient border border-surface-container-highest">
<h3 class="text-body-md font-body-md font-bold mb-1">能不能吃?</h3>
<p class="text-[10px] text-on-surface-variant mb-3">输入食物名称,结合您的档案判断</p>
<div class="flex gap-2">
<input class="flex-1 bg-surface rounded-xl border border-outline-variant px-3 py-2 text-body-md focus:ring-2 focus:ring-primary focus:border-primary outline-none" placeholder="如:白馒头、地瓜、粘豆包" type="text"/>
<button class="bg-primary text-on-primary px-4 py-2 rounded-xl flex items-center gap-1 hover:bg-on-primary-fixed-variant transition-colors">
<span class="material-symbols-outlined text-[18px]">auto_awesome</span>
<span class="text-body-md font-body-md font-bold">问 AI</span>
</button>
</div>
</section>
<!-- Disclaimer -->
<footer class="text-center py-6">
<p class="text-[10px] text-outline">数据仅供参考,请遵医嘱</p>
</footer>
</main>
<!-- BottomNavBar -->
<nav class="fixed bottom-0 w-full z-50 rounded-t-2xl shadow-[0_-4px_20px_0_rgba(0,0,0,0.05)] bg-surface-container-lowest md:hidden flex justify-around items-center h-16 px-4">
<button class="flex flex-col items-center justify-center text-primary font-bold w-16">
<span class="material-symbols-outlined fill-icon mb-1">home</span>
<span class="text-[10px] font-label-md">首页</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
<span class="material-symbols-outlined mb-1">add_circle</span>
<span class="text-[10px] font-label-md">记录</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
<span class="material-symbols-outlined mb-1">analytics</span>
<span class="text-[10px] font-label-md">动态</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant opacity-60 w-16 hover:bg-surface-container-high transition-all rounded-xl py-1">
<span class="material-symbols-outlined mb-1">person</span>
<span class="text-[10px] font-label-md">我的</span>
</button>
</nav>
</body></html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,993 @@
/**
* 日常记录统一主题 与甄养堂小程序 TCM Care 品牌色一致
* 来源pages/index/index.vue · tabBar selectedColor #204e2b
*/
.daily-page {
/* 品牌主色 */
--primary: #204e2b;
--primary-dark: #163d22;
--primary-container: #386641;
--primary-grad: linear-gradient(160deg, #204e2b 0%, #386641 100%);
--primary-light: #eef6ef;
--primary-soft: #cfe8d3;
--primary-glow: rgba(32, 78, 43, 0.14);
--on-primary-container: #afe2b3;
--success: #386641;
--success-light: #eef6ef;
--danger: #dc2626;
--danger-light: #fef2f2;
--danger-glow: rgba(220, 38, 38, 0.1);
--warning: #d97706;
--warning-light: #fff7ed;
--text-primary: #1b1c1a;
--text-secondary: #414941;
--text-muted: #727970;
--surface: #ffffff;
--surface-muted: #f4f4f0;
--border: #e3e2df;
--border-soft: rgba(32, 78, 43, 0.12);
--slate-50: #faf9f5;
--slate-100: #f4f4f0;
--slate-200: #e9e8e4;
--slate-300: #e3e2df;
--slate-400: #727970;
--slate-600: #414941;
--slate-900: #1b1c1a;
--shadow-premium: 0 8rpx 28rpx rgba(32, 78, 43, 0.06);
--shadow-sm: 0 4rpx 16rpx rgba(32, 78, 43, 0.05);
--shadow-glow-primary: 0 8rpx 20rpx rgba(32, 78, 43, 0.18);
--shadow-glow-danger: 0 8rpx 20rpx rgba(220, 38, 38, 0.12);
--border-premium: 1rpx solid var(--border-soft);
background: var(--slate-50);
}
/* Hero:品牌绿渐变 */
.daily-page .hero-bg {
background: var(--primary-grad);
&::after {
background: radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.1) 0%, transparent 55%);
}
}
.daily-page .hero-orb {
display: none;
}
.daily-page .hero-pill.voice.speaking {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(255, 255, 255, 0.95);
animation: none;
box-shadow: 0 0 0 3rpx rgba(255, 255, 255, 0.35);
}
.daily-page .hero-status-chip.pending {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.28);
}
.daily-page .hero-status-chip.done {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.32);
}
/* 指标卡 */
.daily-page .stat-fasting,
.daily-page .stat-postprandial,
.daily-page .stat-other {
background: var(--surface);
}
.daily-page .stat-fasting .stat-card-tag,
.daily-page .stat-postprandial .stat-card-tag,
.daily-page .stat-other .stat-card-tag {
background: var(--primary-light);
color: var(--primary-dark);
}
.daily-page .stat-card-value {
background: none;
-webkit-background-clip: unset;
background-clip: unset;
color: var(--text-primary);
}
.daily-page .stat-card-value.is-high {
background: none;
color: var(--danger);
}
.daily-page .stat-card-trend.trend-down {
background: var(--primary-light);
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: var(--primary-dark);
}
}
.daily-page .more-entry-arrow {
color: var(--primary);
}
.daily-page .card-chip.active {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .range-item.active {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .legend-dot-fasting {
background: var(--primary);
}
.daily-page .legend-dot-postprandial {
background: var(--warning);
}
.daily-page .streak-strip {
background: var(--surface);
border-color: var(--border-soft);
&::after {
background: radial-gradient(circle, rgba(32, 78, 43, 0.08) 0%, transparent 70%);
}
}
.daily-page .streak-num {
color: var(--primary-dark);
}
/* 日历:品牌绿深浅 */
.daily-page .calendar-cell.level-1 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-1 {
background: var(--primary-soft);
border-color: #b8dcc0;
}
.daily-page .calendar-cell.level-2 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-2 {
background: var(--on-primary-container);
border-color: #86c992;
}
.daily-page .calendar-cell.level-3 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-3 {
background: var(--primary);
border-color: var(--primary-dark);
}
.daily-page .day-block-tag.tag-blood,
.daily-page .day-block-tag.tag-diet,
.daily-page .day-block-tag.tag-exercise,
.daily-page .day-block-tag.tag-tracking {
background: var(--primary-light);
color: var(--primary-dark);
border: 1rpx solid var(--border-soft);
}
.daily-page .day-block-tag.tag-diet,
.daily-page .day-block-tag.tag-exercise {
background: var(--surface-muted);
color: var(--text-secondary);
border-color: var(--border);
}
.daily-page .pg-points-badge {
background: var(--primary-light);
border-color: var(--border-soft);
}
.daily-page .pg-points-num {
color: var(--primary-dark);
}
.daily-page .record-quick-chip.pending {
border-color: var(--border);
background: var(--surface-muted);
}
.daily-page .record-quick-chip.done {
border-color: rgba(56, 102, 65, 0.35);
background: var(--success-light);
}
.daily-page .task-action-btn.pending {
background: var(--primary);
}
.daily-page .water-btn {
background: var(--primary);
}
.daily-page .family-like-strip,
.daily-page .encourage-strip {
background: var(--surface);
border: 1rpx solid var(--border);
box-shadow: var(--shadow-premium);
}
.daily-page .card-title,
.daily-page .hero-title {
color: inherit;
}
.daily-page .report-share-btn,
.daily-page .report-share-btn::after {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .input-modal-btn.primary {
background: var(--primary);
}
.daily-page .input-field-dot-fasting {
background: var(--primary);
}
.daily-page .input-field-dot-postprandial {
background: var(--primary-container);
}
.daily-page .input-field-dot-other {
background: var(--text-muted);
}
.daily-page .voice-switch.on .voice-switch-track {
background: var(--primary);
}
.daily-page .heatmap-foot-cta {
background: var(--primary);
}
/* ========== 适老极简首页(index ========== */
.daily-page .elder-header {
padding: 48rpx 32rpx 24rpx;
}
.daily-page .elder-greet {
display: block;
font-size: 44rpx;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
}
.daily-page .elder-status {
display: block;
margin-top: 16rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1.4;
&.is-done {
color: var(--success);
}
&.is-pending {
color: var(--text-secondary);
}
}
.daily-page .elder-card-scroll {
margin-top: 24rpx;
width: 100%;
white-space: nowrap;
}
.daily-page .elder-card-row {
display: inline-flex;
flex-wrap: nowrap;
gap: 16rpx;
padding: 2rpx 0;
}
.daily-page .elder-card-chip {
flex-shrink: 0;
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: var(--surface);
border: 2rpx solid var(--border);
text {
font-size: 28rpx;
color: var(--text-secondary);
}
&.active {
background: var(--primary);
border-color: var(--primary);
text {
color: #fff;
font-weight: 600;
}
}
}
.daily-page .elder-actions {
padding: 0 32rpx 24rpx;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 16rpx;
}
.daily-page .elder-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
min-height: 112rpx;
min-width: 0;
border-radius: 24rpx;
box-shadow: var(--shadow-sm);
box-sizing: border-box;
}
.daily-page .elder-btn-text {
font-size: 36rpx;
font-weight: 700;
}
.daily-page .elder-btn-primary {
flex: 8;
background: var(--primary);
.elder-btn-text {
color: #fff;
}
}
.daily-page .elder-btn-voice {
flex: 2;
flex-direction: column;
gap: 8rpx;
padding: 12rpx 8rpx;
background: var(--surface);
border: 2rpx solid var(--border-soft);
.elder-btn-text {
color: var(--primary-dark);
font-size: 24rpx;
line-height: 1.25;
text-align: center;
}
&.speaking {
border-color: var(--primary);
background: var(--primary-light);
}
&.disabled {
opacity: 0.45;
}
}
.daily-page .elder-today-panel {
margin: 0 32rpx 24rpx;
padding: 32rpx;
background: var(--surface);
border-radius: 24rpx;
border: 1rpx solid var(--border);
box-shadow: var(--shadow-premium);
}
.daily-page .elder-panel-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24rpx;
}
.daily-page .elder-today-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.daily-page .elder-metric {
padding: 24rpx;
border-radius: 20rpx;
background: var(--surface-muted);
border: 2rpx solid var(--border);
&.is-high {
border-color: rgba(220, 38, 38, 0.35);
background: var(--danger-light);
}
}
.daily-page .elder-metric-label {
display: block;
font-size: 28rpx;
color: var(--text-secondary);
margin-bottom: 12rpx;
}
.daily-page .elder-metric-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.daily-page .elder-metric-value {
font-size: 56rpx;
font-weight: 800;
color: var(--text-primary);
line-height: 1.1;
}
.daily-page .elder-metric.is-high .elder-metric-value {
color: var(--danger);
}
.daily-page .elder-metric-unit {
font-size: 26rpx;
color: var(--text-muted);
}
.daily-page .elder-metric-flag {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
color: var(--danger);
font-weight: 600;
}
.daily-page .elder-more-link {
margin: 0 32rpx 16rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface);
border-radius: 24rpx;
border: 1rpx solid var(--border);
}
.daily-page .elder-more-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: var(--text-primary);
}
.daily-page .elder-more-sub {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: var(--text-muted);
line-height: 1.45;
}
.daily-page .elder-more-arrow {
font-size: 48rpx;
color: var(--primary);
font-weight: 300;
padding-left: 16rpx;
}
.daily-page .footer-tip {
text-align: center;
font-size: 26rpx;
color: var(--text-muted);
padding: 16rpx 32rpx 48rpx;
line-height: 1.6;
}
.daily-page .elder-chart-block .range-bar {
margin-bottom: 16rpx;
}
.daily-page .glucose-history-block {
padding-bottom: 8rpx;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
/**
* Google Stitch MCP · VitalMint Health
* 设计 token stitch-weekly.html tailwind.config 一一对应
*/
.stitch-vitalmint {
--primary: #006c49;
--on-primary: #ffffff;
--on-primary-fixed-variant: #005236;
--primary-container: #10b981;
--on-primary-container: #00422b;
--inverse-primary: #4edea3;
--surface: #f4fbf4;
--on-surface: #161d19;
--on-surface-variant: #3c4a42;
--surface-container-lowest: #ffffff;
--surface-container-low: #eef6ee;
--surface-container: #e8f0e9;
--surface-container-high: #e3eae3;
--surface-container-highest: #dde4dd;
--surface-variant: #dde4dd;
--outline: #6c7a71;
--outline-variant: #bbcabf;
--error: #ba1a1a;
--on-error-container: #93000a;
--error-container: #ffdad6;
--tertiary: #a43a3a;
--tertiary-container: #fc7c78;
--on-tertiary: #ffffff;
--on-tertiary-container: #711419;
--st-container-margin: 40rpx;
--st-section-gap: 64rpx;
--st-card-padding: 40rpx;
--st-stack-gap: 32rpx;
--st-inline-gap: 24rpx;
--st-radius-2xl: 48rpx;
--st-radius-xl: 24rpx;
--st-radius-lg: 16rpx;
--st-shadow-ambient: 0 20rpx 60rpx -20rpx rgba(0, 108, 73, 0.08);
}
.weekly-page.stitch-vitalmint {
min-height: 100vh;
background: var(--surface);
color: var(--on-surface);
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
}
@@ -0,0 +1,293 @@
/* weekly.vue 录入弹窗 — 沿用 MCP 主色 */
.weekly-page .input-modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(12px);
z-index: 2000;
display: flex;
align-items: flex-end;
justify-content: center;
animation: input-modal-mask-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.weekly-page .input-modal {
width: 100%;
max-width: 750rpx;
background: var(--surface-container-lowest);
border-radius: 36rpx 36rpx 0 0;
padding: 16rpx 0 calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -12rpx 36rpx rgba(15, 23, 42, 0.12);
max-height: 88vh;
display: flex;
flex-direction: column;
animation: input-modal-rise 0.46s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes input-modal-mask-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes input-modal-rise {
from { transform: translate3d(0, 100%, 0); opacity: 0.96; }
to { transform: translate3d(0, 0, 0); opacity: 1; }
}
@keyframes input-modal-content-in {
from { opacity: 0; transform: translate3d(0, 24rpx, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
.weekly-page .input-modal-grip {
width: 80rpx;
height: 6rpx;
border-radius: 999rpx;
background: var(--surface-container-highest);
margin: 0 auto 8rpx;
}
.weekly-page .input-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 32rpx 16rpx;
border-bottom: 1rpx solid var(--surface-container-low);
}
.weekly-page .input-modal-title {
font-size: 40rpx;
font-weight: 800;
color: var(--on-surface);
}
.weekly-page .input-modal-sub {
font-size: 26rpx;
color: var(--on-surface-variant);
margin-top: 4rpx;
}
.weekly-page .input-modal-close {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
background: var(--surface-container-low);
}
.weekly-page .input-modal-close-icon {
font-size: 42rpx;
color: var(--outline);
line-height: 1;
font-weight: 700;
}
.weekly-page .phone-gate-body {
padding: 32rpx;
}
.weekly-page .phone-gate-tip {
display: block;
font-size: 28rpx;
color: var(--on-surface-variant);
line-height: 1.6;
margin-bottom: 24rpx;
}
.weekly-page .phone-gate-gender-label {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--primary);
margin-bottom: 16rpx;
}
.weekly-page .phone-gate-gender-row {
display: flex;
gap: 20rpx;
margin-bottom: 32rpx;
}
.weekly-page .phone-gate-gender-opt {
flex: 1;
min-height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--st-radius-xl);
background: var(--surface-container-lowest);
border: 2rpx solid var(--outline-variant);
color: var(--on-surface-variant);
font-size: 32rpx;
font-weight: 600;
}
.weekly-page .phone-gate-gender-opt.active {
border-color: var(--primary);
color: var(--primary);
}
.weekly-page .phone-gate-btn {
width: 100%;
min-height: 96rpx;
line-height: 96rpx;
border-radius: var(--st-radius-xl);
background: var(--primary);
color: var(--on-primary);
font-size: 32rpx;
font-weight: 700;
border: none;
}
.weekly-page .phone-gate-btn::after {
border: none;
}
.weekly-page .input-modal-loading {
min-height: 520rpx;
padding: 120rpx 0;
text-align: center;
color: var(--outline);
font-size: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
.weekly-page .input-modal-body {
flex: 1;
min-height: 520rpx;
padding: 20rpx 32rpx 28rpx;
box-sizing: border-box;
width: 100%;
}
.weekly-page .input-modal-body-ready {
animation: input-modal-content-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.weekly-page .input-modal-tip {
background: var(--surface-container-low);
border: 1rpx dashed var(--outline-variant);
border-radius: 14rpx;
padding: 18rpx 20rpx;
font-size: 26rpx;
color: var(--on-surface-variant);
line-height: 1.5;
margin-bottom: 22rpx;
}
.weekly-page .input-section-title {
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
margin: 18rpx 0 14rpx;
}
.weekly-page .input-field {
display: flex;
align-items: center;
gap: 18rpx;
padding: 14rpx 0;
border-bottom: 1rpx solid var(--surface-container-low);
min-height: 88rpx;
}
.weekly-page .input-field-label {
display: flex;
align-items: center;
gap: 10rpx;
width: 180rpx;
font-size: 30rpx;
color: var(--on-surface);
font-weight: 700;
}
.weekly-page .input-field-dot {
width: 14rpx;
height: 14rpx;
border-radius: 999rpx;
}
.weekly-page .input-field-dot-fasting {
background: var(--primary);
}
.weekly-page .input-field-dot-postprandial {
background: var(--tertiary-container);
}
.weekly-page .input-field-dot-other {
background: var(--outline);
}
.weekly-page .input-field-input {
flex: 1;
min-width: 0;
background: var(--surface);
border-radius: 16rpx;
padding: 18rpx 20rpx;
min-height: 76rpx;
border: 2rpx solid transparent;
}
.weekly-page .input-field-input input {
width: 100%;
font-size: 34rpx;
color: var(--on-surface);
text-align: right;
font-weight: 600;
}
.weekly-page .input-placeholder {
color: var(--outline);
font-size: 28rpx;
}
.weekly-page .input-field-textarea textarea {
width: 100%;
min-height: 140rpx;
background: var(--surface);
border-radius: 14rpx;
padding: 20rpx 22rpx;
font-size: 30rpx;
color: var(--on-surface);
border: 2rpx solid var(--outline-variant);
}
.weekly-page .input-modal-actions {
display: flex;
gap: 18rpx;
padding: 20rpx 32rpx 0;
border-top: 1rpx solid var(--surface-container-low);
}
.weekly-page .input-modal-btn {
flex: 1;
height: 100rpx;
border-radius: var(--st-radius-xl);
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 700;
}
.weekly-page .input-modal-btn.primary {
background: var(--primary);
color: var(--on-primary);
}
.weekly-page .input-modal-btn.danger {
flex: 0 0 180rpx;
background: var(--error-container);
color: var(--error);
}
.weekly-page .input-modal-btn.disabled {
opacity: 0.5;
pointer-events: none;
}
@@ -0,0 +1,915 @@
/**
* weekly.vue code.html (Stitch Modern WeChat UI) 1:1
*/
.weekly-page {
--st-shadow-ambient: 0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08), 0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
--st-shadow-cta: 0 16rpx 32rpx rgba(0, 108, 73, 0.2);
--st-shadow-float: 0 16rpx 64rpx rgba(0, 108, 73, 0.15);
--tab-dock-full-offset: calc(220rpx + env(safe-area-inset-bottom));
padding-bottom: var(--tab-dock-full-offset);
}
/* ===== Header ===== */
.weekly-page .st-header {
position: relative;
display: flex;
align-items: center;
padding-left: var(--st-container-margin);
padding-right: var(--st-container-margin);
padding-bottom: 32rpx;
/* padding-top / padding-right 由 initHeaderSafeArea 动态设置 */
background: var(--surface);
}
.weekly-page .st-header-text {
flex: 1;
min-width: 0;
overflow: hidden;
}
.weekly-page .st-header-left {
display: flex;
align-items: center;
gap: var(--st-inline-gap);
flex: 1;
min-width: 0;
}
.weekly-page .st-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: var(--primary-container);
border: 4rpx solid var(--surface-container-lowest);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-greet {
display: block;
font-size: 40rpx;
font-weight: 700;
line-height: 1.27;
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weekly-page .st-status {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
opacity: 0.8;
}
.weekly-page .st-voice-round {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-voice-round.disabled {
opacity: 0.45;
}
.weekly-page .st-card-scroll {
margin: 0 var(--st-container-margin) 8rpx;
white-space: nowrap;
}
.weekly-page .st-card-row {
display: inline-flex;
gap: 16rpx;
}
.weekly-page .st-card-chip {
padding: 12rpx 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 600;
color: var(--on-surface-variant);
background: var(--surface-container-lowest);
border: 1rpx solid var(--outline-variant);
}
.weekly-page .st-card-chip.active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
/* ===== 居家训练(DEV 内嵌,与 user 页同源入口) ===== */
.weekly-page .st-training-module {
padding: 10rpx var(--st-container-margin);
margin: -10rpx -30rpx 10rpx -30rpx;
}
/* ===== Main ===== */
.weekly-page .st-main {
padding: 0 var(--st-container-margin) 32rpx;
display: flex;
flex-direction: column;
gap: 48rpx;
margin-top: 16rpx;
}
.weekly-page .st-main-spacer {
height: 32rpx;
}
/* CTA — rounded-2xl h-14 */
.weekly-page .st-cta {
width: 100%;
height: 112rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
border-radius: var(--st-radius-2xl);
background: var(--primary);
color: var(--on-primary);
font-size: 32rpx;
font-weight: 700;
box-shadow: var(--st-shadow-cta);
}
.weekly-page .st-cta:active {
transform: scale(0.98);
}
/* 糖分突袭游戏入口 */
.weekly-page .st-game-entry {
width: 100%;
margin-top: 24rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
border-radius: var(--st-radius-2xl);
background: var(--surface-container-lowest);
border: 1rpx solid rgba(0, 108, 73, 0.14);
box-shadow: var(--st-shadow-ambient);
box-sizing: border-box;
}
.weekly-page .st-game-entry:active {
transform: scale(0.98);
opacity: 0.92;
}
.weekly-page .st-game-entry-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-game-entry-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.weekly-page .st-game-entry-title {
font-size: 32rpx;
font-weight: 700;
color: var(--on-surface);
line-height: 1.25;
}
.weekly-page .st-game-entry-sub {
font-size: 24rpx;
font-weight: 500;
color: var(--on-surface-variant, #64748b);
line-height: 1.35;
}
/* ===== Chart card — rounded-3xl ===== */
.weekly-page .st-chart-card {
padding: 40rpx 10rpx 40rpx 10rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest);
border: 1rpx solid rgba(221, 228, 221, 0.3);
box-shadow: var(--st-shadow-ambient);
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.weekly-page .st-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
gap: 16rpx;
}
.weekly-page .st-chart-title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-chart-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-chart-more {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-top: 6rpx;
}
.weekly-page .st-chart-more-text {
font-size: 22rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-chart-more:active {
opacity: 0.75;
}
.weekly-page .st-chart-legend {
display: flex;
gap: var(--st-inline-gap);
}
.weekly-page .st-legend-item {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-legend-item.inactive {
opacity: 0.4;
}
.weekly-page .st-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.weekly-page .st-dot-fasting {
background: var(--primary);
}
.weekly-page .st-dot-post {
background: var(--tertiary);
}
.weekly-page .st-chart-summary {
display: flex;
padding: 32rpx;
margin-bottom: 48rpx;
border-radius: var(--st-radius-xl);
background: var(--surface-container-low);
}
.weekly-page .st-chart-summary-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1rpx solid var(--surface-container-highest);
}
.weekly-page .st-chart-summary-item:last-child {
border-right: none;
}
.weekly-page .st-chart-summary-num {
font-size: 40rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-chart-summary-num.is-high {
color: var(--tertiary);
}
.weekly-page .st-chart-summary-num.is-good {
color: var(--primary);
}
.weekly-page .st-chart-summary-label {
margin-top: 8rpx;
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-chart-foot {
padding: 20rpx 0 20rpx;
}
.weekly-page .st-chart-mock-hint {
display: block;
margin: 0;
font-size: 30rpx;
font-weight: 600;
color: var(--outline);
line-height: 1.35;
text-align: center;
}
.weekly-page .st-chart-area {
position: relative;
height: 360rpx;
overflow: hidden;
cursor: pointer;
}
.weekly-page .st-chart-area:active {
opacity: 0.92;
}
.weekly-page .st-chart-canvas {
width: 100%;
height: 360rpx;
display: block;
}
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
.weekly-page .st-chart-card .st-game-entry--in-card {
margin-top: 12rpx;
padding: 16rpx 20rpx;
gap: 16rpx;
border-radius: 24rpx;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.08);
box-shadow: none;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-title {
font-size: 28rpx;
}
.weekly-page .st-chart-card .st-game-entry--in-card .st-game-entry-sub {
font-size: 22rpx;
}
.weekly-page .st-chart-empty,
.weekly-page .st-chart-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: var(--outline);
}
/* ===== 最新测量 ===== */
.weekly-page .st-latest-section {
display: flex;
flex-direction: column;
gap: 32rpx;
}
.weekly-page .st-latest-hero {
position: relative;
overflow: hidden;
min-height: 320rpx;
padding: 48rpx 40rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest);
box-shadow: var(--st-shadow-ambient);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.weekly-page .st-latest-hero.is-high {
background: var(--error-container);
color: var(--on-error-container);
}
.weekly-page .st-latest-hero-pattern {
position: absolute;
inset: 0;
opacity: 0.1;
background-image: radial-gradient(circle at 4rpx 4rpx, currentColor 2rpx, transparent 0);
background-size: 32rpx 32rpx;
pointer-events: none;
}
.weekly-page .st-latest-label {
position: relative;
z-index: 1;
font-size: 24rpx;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.9;
margin-bottom: 8rpx;
}
.weekly-page .st-latest-value-row {
position: relative;
z-index: 1;
display: flex;
align-items: baseline;
gap: 8rpx;
}
.weekly-page .st-latest-value {
font-size: 112rpx;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
}
.weekly-page .st-latest-hero.is-high .st-latest-value,
.weekly-page .st-latest-hero.is-high .st-latest-unit {
color: var(--on-error-container);
}
.weekly-page .st-latest-unit {
font-size: 32rpx;
font-weight: 500;
opacity: 0.8;
}
.weekly-page .st-latest-flag {
position: relative;
z-index: 1;
margin-top: 24rpx;
display: inline-flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 24rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.3);
font-size: 24rpx;
font-weight: 700;
}
.weekly-page .st-summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24rpx;
}
.weekly-page .st-summary-card {
padding: 32rpx;
border-radius: var(--st-radius-2xl);
background: var(--surface-container-lowest);
border: 1rpx solid rgba(221, 228, 221, 0.5);
box-shadow: var(--st-shadow-ambient);
display: flex;
flex-direction: column;
gap: 8rpx;
}
.weekly-page .st-summary-label {
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-summary-value {
font-size: 48rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-summary-value.is-high {
color: var(--tertiary);
}
.weekly-page .st-summary-tag {
align-self: flex-start;
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 20rpx;
font-weight: 600;
color: var(--tertiary);
background: rgba(255, 218, 214, 0.5);
}
/* ===== AI 饮食 ===== */
.weekly-page .st-ai-section {
position: relative;
overflow: hidden;
padding: 40rpx;
border-radius: 48rpx;
background: linear-gradient(135deg, rgba(0, 108, 73, 0.1) 0%, rgba(0, 108, 73, 0.05) 100%);
border: 1rpx solid rgba(0, 108, 73, 0.1);
}
.weekly-page .st-ai-deco {
position: absolute;
width: 256rpx;
height: 256rpx;
top: -64rpx;
right: -64rpx;
border-radius: 50%;
background: rgba(78, 222, 163, 0.2);
filter: blur(40rpx);
pointer-events: none;
}
.weekly-page .st-ai-head {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.weekly-page .st-ai-head-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.weekly-page .st-ai-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: var(--primary);
display: flex;
align-items: center;
justify-content: center;
}
.weekly-page .st-ai-icon.thinking {
animation: st-pulse 1.2s ease-in-out infinite;
}
@keyframes st-pulse {
50% { opacity: 0.65; }
}
.weekly-page .st-ai-title {
font-size: 40rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-ai-refresh {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.5);
font-size: 24rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-ai-refresh.disabled {
opacity: 0.5;
}
.weekly-page .st-ai-sub {
position: relative;
z-index: 1;
display: block;
margin-bottom: 32rpx;
font-size: 28rpx;
line-height: 1.43;
color: var(--on-surface-variant);
}
.weekly-page .st-meal-list {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.weekly-page .st-meal-glass {
padding: 32rpx;
border-radius: var(--st-radius-2xl);
background: rgba(255, 255, 255, 0.8);
border: 1rpx solid #ffffff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
}
.weekly-page .st-meal-head {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.weekly-page .st-meal-label {
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-meal-text {
font-size: 28rpx;
line-height: 1.43;
color: var(--on-surface-variant);
}
.weekly-page .st-skeleton-line {
height: 28rpx;
margin-bottom: 16rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.5);
}
.weekly-page .st-skeleton-hint {
font-size: 26rpx;
color: var(--outline);
text-align: center;
}
/* ===== 底部浮动输入 ===== */
.weekly-page .st-float-ask {
position: fixed;
left: var(--st-container-margin);
right: var(--st-container-margin);
bottom: var(--tab-dock-full-offset);
z-index: 210;
}
.weekly-page .st-float-close {
position: absolute;
top: -18rpx;
right: -6rpx;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.weekly-page .st-float-close-icon {
color: #ffffff;
font-size: 30rpx;
line-height: 1;
}
.weekly-page .st-float-reopen {
position: fixed;
right: var(--st-container-margin);
bottom: var(--tab-dock-full-offset);
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
box-shadow: var(--st-shadow-float);
display: flex;
align-items: center;
justify-content: center;
z-index: 210;
}
.weekly-page .st-float-inner {
display: flex;
align-items: center;
gap: 24rpx;
padding: 16rpx 16rpx 16rpx 40rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20rpx);
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-shadow: var(--st-shadow-float);
}
.weekly-page .st-float-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-float-input {
flex: 1;
min-width: 0;
height: 64rpx;
font-size: 28rpx;
color: var(--on-surface);
background: transparent;
}
.weekly-page .st-float-placeholder {
color: rgba(60, 74, 66, 0.6);
font-size: 28rpx;
}
.weekly-page .st-float-mic {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.2s ease, transform 0.15s ease;
}
.weekly-page .st-float-mic.listening {
background: var(--primary);
animation: st-float-mic-pulse 1.2s ease-in-out infinite;
}
.weekly-page .st-float-mic.disabled {
opacity: 0.45;
pointer-events: none;
}
.weekly-page .st-float-mic:active {
transform: scale(0.94);
}
.weekly-page .st-float-hold-hint {
margin-top: 12rpx;
text-align: center;
font-size: 24rpx;
color: var(--primary);
line-height: 1.4;
}
.weekly-page .st-voice-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.32);
pointer-events: none;
}
.weekly-page .st-voice-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
padding: 56rpx 64rpx;
border-radius: 32rpx;
background: rgba(20, 24, 22, 0.86);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
}
.weekly-page .st-voice-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
}
.weekly-page .st-voice-icon.active {
animation: st-voice-pulse 1.1s ease-in-out infinite;
}
@keyframes st-voice-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.45); }
50% { box-shadow: 0 0 0 20rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-voice-title {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.3;
}
.weekly-page .st-voice-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
line-height: 1.3;
}
@keyframes st-float-mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.35); }
50% { box-shadow: 0 0 0 12rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-float-send {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.2);
line-height: 0;
overflow: hidden;
}
.weekly-page .st-float-send.disabled {
opacity: 0.55;
}
.weekly-page .st-float-send:active {
transform: scale(0.95);
}
.weekly-page .st-float-result {
margin-top: 16rpx;
padding: 24rpx 32rpx;
border-radius: var(--st-radius-xl);
background: rgba(255, 255, 255, 0.95);
border: 1rpx solid var(--outline-variant);
box-shadow: var(--st-shadow-ambient);
}
.weekly-page .st-float-badge {
display: inline-block;
margin-bottom: 8rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
background: rgba(0, 108, 73, 0.1);
}
.weekly-page .st-float-advice {
font-size: 28rpx;
line-height: 1.5;
color: var(--on-surface-variant);
}
.weekly-page .ai-stream-cursor {
animation: st-blink 0.8s step-end infinite;
}
@keyframes st-blink {
50% { opacity: 0; }
}
.weekly-page .chart-tip {
position: absolute;
z-index: 10;
padding: 12rpx 16rpx;
border-radius: 12rpx;
background: rgba(22, 29, 25, 0.88);
color: #fff;
font-size: 22rpx;
pointer-events: none;
}
.weekly-page .chart-tip-date {
font-weight: 700;
margin-bottom: 8rpx;
}
.weekly-page .chart-tip-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 4rpx;
}
@@ -0,0 +1,168 @@
/** 拼接 API 根路径与相对路径,避免双斜杠 */
export function joinApiUrl(base, path) {
const b = String(base || '').replace(/\/+$/, '')
const p = String(path || '').replace(/^\/+/, '')
return p ? `${b}/${p}` : b
}
function appendQuery(url, data) {
if (!data || typeof data !== 'object') return url
const keys = Object.keys(data)
if (!keys.length) return url
const qs = keys
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(data[k] ?? '')}`)
.join('&')
return url + (url.includes('?') ? '&' : '?') + qs
}
function decodeChunkData(data) {
if (typeof data === 'string') return data
if (!data) return ''
if (typeof TextDecoder !== 'undefined' && data instanceof ArrayBuffer) {
return new TextDecoder('utf-8').decode(new Uint8Array(data))
}
if (data instanceof ArrayBuffer) {
const bytes = new Uint8Array(data)
let str = ''
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i])
try {
return decodeURIComponent(escape(str))
} catch (e) {
return str
}
}
return ''
}
/** 从 SSE 文本缓冲中解析并消费完整事件 */
export function consumeSseBuffer(buffer, onEvent) {
let rest = buffer
let idx = rest.indexOf('\n\n')
while (idx !== -1) {
const block = rest.slice(0, idx)
rest = rest.slice(idx + 2)
let event = 'message'
let dataLine = ''
block.split('\n').forEach((line) => {
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) dataLine += line.slice(5).trim()
})
if (dataLine) {
try {
onEvent(event, JSON.parse(dataLine))
} catch (e) {
onEvent(event, dataLine)
}
}
idx = rest.indexOf('\n\n')
}
return rest
}
/** 从流式纯文本中提取三餐(支持未写完的最后一行) */
export function parsePartialDietPlainText(text) {
const out = {}
if (!text) return out
const lineRe = /^(早餐|午餐|晚餐|提示|少碰)[:]\s*(.*)$/gm
let match
while ((match = lineRe.exec(text)) !== null) {
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
const val = (match[2] || '').trim()
if (key === 'avoid') {
out.avoid = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
} else {
out[key] = val
}
}
const tail = text.match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[:]\s*([^\n]*)$/)
if (tail) {
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
const val = (tail[2] || '').trim()
if (key === 'avoid') {
if (val) out.avoid = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
} else {
out[key] = val
}
}
return out
}
/** @deprecated 使用 parsePartialDietPlainText */
export function parsePartialDietJson(text) {
return parsePartialDietPlainText(text)
}
/**
* SSE 流式请求微信小程序 enableChunked其它端走 fallback
*
* @param {object} opts
* @param {string} opts.baseUrl
* @param {string} opts.url
* @param {string} [opts.method]
* @param {object} [opts.data]
* @param {function(string, object):void} opts.onEvent
* @param {function():Promise<object>} [opts.fallback] 不支持流式时的降级请求
*/
export function requestAiStream(opts) {
const {
baseUrl,
url,
method = 'GET',
data = {},
onEvent,
fallback
} = opts
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') || ''
let sseBuffer = ''
let requestUrl = joinApiUrl(baseUrl, url)
if (method === 'GET') {
requestUrl = appendQuery(requestUrl, data)
}
const task = wx.request({
url: requestUrl,
method,
data: method === 'POST' ? data : {},
enableChunked: true,
timeout: 60000,
header: {
token,
'content-type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache'
},
success: (res) => {
if (res && res.data) {
sseBuffer += decodeChunkData(res.data)
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
}
resolve(res)
},
fail: (err) => reject(err)
})
if (task && typeof task.onChunkReceived === 'function') {
task.onChunkReceived((res) => {
sseBuffer += decodeChunkData(res.data)
sseBuffer = consumeSseBuffer(sseBuffer, onEvent)
})
} else if (typeof fallback === 'function') {
fallback().then(resolve).catch(reject)
} else {
reject(new Error('当前环境不支持流式请求'))
}
})
// #endif
// #ifndef MP-WEIXIN
if (typeof fallback === 'function') {
return fallback()
}
return Promise.reject(new Error('当前环境不支持流式请求'))
// #endif
}
@@ -0,0 +1,49 @@
/**
* SVG 字符串转为小程序可用的 data URLbase64 在真机上更稳定
*/
function utf8ToBytes(str) {
const encoded = encodeURIComponent(str)
const bytes = []
for (let i = 0; i < encoded.length; i++) {
if (encoded.charCodeAt(i) === 37) {
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
i += 2
} else {
bytes.push(encoded.charCodeAt(i))
}
}
return new Uint8Array(bytes)
}
function bytesToBase64(bytes) {
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
return uni.arrayBufferToBase64(bytes.buffer)
}
if (typeof btoa !== 'undefined') {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
let result = ''
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i]
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
result += chars[a >> 2]
result += chars[((a & 3) << 4) | (b >> 4)]
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
result += i + 2 < bytes.length ? chars[c & 63] : '='
}
return result
}
export function svgToDataUrl(svg) {
const normalized = String(svg || '').trim()
if (!normalized) return ''
const base64 = bytesToBase64(utf8ToBytes(normalized))
return `data:image/svg+xml;base64,${base64}`
}
@@ -0,0 +1,56 @@
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
export function formatUserMessage(msg, fallback = '') {
if (msg == null || msg === '') return fallback
if (typeof msg === 'string') return msg
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
if (Array.isArray(msg)) {
const parts = msg.map((item) => {
if (item == null) return ''
if (typeof item === 'string') return item
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
if (typeof item === 'object') {
return item.msg || item.message || item.error || item.title || item.label || ''
}
return String(item)
}).filter(Boolean)
return parts.length ? parts.join('') : fallback
}
if (typeof msg === 'object') {
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
}
return String(msg)
}
export function showUserToast(title, options = {}) {
const text = formatUserMessage(title, '')
if (!text) return
uni.showToast({
title: text,
icon: options.icon || 'none',
duration: options.duration
})
}
export function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function parseDate(str) {
if (!str) return new Date()
const [y, m, d] = str.split('-').map(Number)
return new Date(y, (m || 1) - 1, d || 1)
}
export function toNumber(v) {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return Number.isFinite(n) && n !== 0 ? n : null
}
export function getWeekday(dateStr) {
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return w[parseDate(dateStr).getDay()]
}
@@ -0,0 +1,58 @@
/** 控糖树等级配置(前后端算法需保持一致:每级 50 积分,最高 Lv.9) */
export const TREE_MAX_LEVEL = 9
export const TREE_XP_PER_LEVEL = 50
export const TREE_LEVELS = [
{ level: 0, name: '种子眠', emoji: '🫘', mood: '困困', desc: '稳糖种子在土里打盹' },
{ level: 1, name: '破土芽', emoji: '🌱', mood: '探头', desc: '探出第一抹新绿' },
{ level: 2, name: '展两叶', emoji: '🌿', mood: '好奇', desc: '两片嫩叶迎风展' },
{ level: 3, name: '小树苗', emoji: '🪴', mood: '精神', desc: '身子骨硬朗起来' },
{ level: 4, name: '青枝繁', emoji: '🌳', mood: '茁壮', desc: '枝叶渐密,元气足' },
{ level: 5, name: '拔节高', emoji: '🌲', mood: '挺拔', desc: '一节一节往上蹿' },
{ level: 6, name: '稳糖冠', emoji: '💚', mood: '沉稳', desc: '树冠成形,习惯成自然' },
{ level: 7, name: '初绽香', emoji: '🌸', mood: '开心', desc: '枝头冒出第一朵花' },
{ level: 8, name: '漫开花', emoji: '🌺', mood: '灿烂', desc: '花开满枝,越记越稳' },
{ level: 9, name: '圆满树', emoji: '🏆', mood: '荣耀', desc: '满级大树,习惯大师' }
]
const WHISPERS_BY_LEVEL = {
0: ['种子在睡觉,记一笔就醒啦。', '今天浇第一滴水,芽就要冒出来。'],
1: ['破土啦!再坚持几天就长高。', '小芽最喜欢规律的记录了。'],
2: ['两片叶子为你鼓掌。', '空腹餐后都记全,我会长得更快。'],
3: ['我已经是一棵小树啦。', '家人点赞的时候,我也会发光。'],
4: ['枝叶越来越密,您真棒。', '连续记录,我会开出更多叶子。'],
5: ['拔节中!习惯比完美更重要。', '再浇一点水,我就更高啦。'],
6: ['习惯成自然,树冠成形啦。', '您今天的坚持,小树都记得。'],
7: ['开花啦!闻到春天的味道了吗?', '稳糖花只开给坚持的人。'],
8: ['满树花香,您已是控糖达人。', '明天继续来,花儿会更艳。'],
9: ['满级大树陪您一路稳糖。', '圆满不是终点,习惯才是。']
}
export function calcTreeFromPoints(points) {
const pts = Math.max(0, Number(points) || 0)
const level = Math.min(TREE_MAX_LEVEL, Math.floor(pts / TREE_XP_PER_LEVEL))
const xpInLevel = level >= TREE_MAX_LEVEL ? TREE_XP_PER_LEVEL : pts % TREE_XP_PER_LEVEL
const progress = level >= TREE_MAX_LEVEL ? 100 : Math.round((xpInLevel / TREE_XP_PER_LEVEL) * 100)
const meta = TREE_LEVELS[level] || TREE_LEVELS[0]
const nextMeta = level < TREE_MAX_LEVEL ? TREE_LEVELS[level + 1] : null
const pointsToNext = level >= TREE_MAX_LEVEL ? 0 : TREE_XP_PER_LEVEL - xpInLevel
return {
level,
progress,
name: meta.name,
emoji: meta.emoji,
mood: meta.mood,
desc: meta.desc,
xpInLevel,
xpNeed: TREE_XP_PER_LEVEL,
pointsToNext,
nextName: nextMeta?.name || '',
isMax: level >= TREE_MAX_LEVEL
}
}
export function pickTreeWhisper(level) {
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
const list = WHISPERS_BY_LEVEL[lv] || WHISPERS_BY_LEVEL[0]
return list[Math.floor(Math.random() * list.length)]
}
+67 -8
View File
@@ -8,6 +8,8 @@ export interface MetronomeOptions {
accentSrc?: string
accentEvery?: number
poolSize?: number
volume?: number // 节拍音音量 0-1
silent?: boolean // 静音模式:只驱动节拍回调,不发声(如握力环只需视觉节拍)
onBeat?: (beatIndex: number, isAccent: boolean) => void
}
@@ -21,11 +23,15 @@ class AudioPool {
private list: UniApp.InnerAudioContext[] = []
private cursor = 0
private warmedUp = false
private targetVolume = 1
constructor(
private src: string,
private size: number,
) {}
volume = 1,
) {
this.targetVolume = volume
}
private create() {
for (let i = 0; i < this.size; i++) {
@@ -33,14 +39,23 @@ class AudioPool {
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.targetVolume
this.list.push(ctx)
}
}
setVolume(volume: number) {
this.targetVolume = Math.max(0, Math.min(1, volume))
this.list.forEach((ctx) => {
try {
ctx.volume = this.targetVolume
} catch (_) {}
})
}
/**
* ,/
* ,/
* play
* 注意:iOS volume = 0 , 0.01
*/
warmUp() {
if (this.warmedUp) return
@@ -48,12 +63,12 @@ class AudioPool {
this.list.forEach((ctx) => {
try {
ctx.volume = 0.01
ctx.volume = 0 // 静音预热,避免进入页面时响声
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = 1
ctx.volume = this.targetVolume // 恢复目标音量
} catch (_) {}
}, 60)
} catch (_) {}
@@ -69,8 +84,11 @@ class AudioPool {
const ctx = this.list[this.cursor]
this.cursor = (this.cursor + 1) % this.list.length
try {
// 强制停止所有正在播放的音频,避免重叠
this.list.forEach(c => {
try { c.stop() } catch (_) {}
})
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
ctx.stop()
ctx.play()
} catch (_) {
try { ctx.play() } catch (__) {}
@@ -101,6 +119,8 @@ export function useMetronome(options: MetronomeOptions = {}) {
clickSrc = DEFAULT_CLICK_SRC,
accentSrc,
poolSize = 4,
volume = 1,
silent = false,
onBeat,
} = options
@@ -118,12 +138,13 @@ export function useMetronome(options: MetronomeOptions = {}) {
let nextTickAt = 0
const ensureAudio = () => {
if (silent) return
if (!clickPool) {
clickPool = new AudioPool(clickSrc, poolSize)
clickPool = new AudioPool(clickSrc, poolSize, volume)
clickPool.warmUp()
}
if (accentSrc && !accentPool) {
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)))
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)), volume)
accentPool.warmUp()
}
}
@@ -179,6 +200,43 @@ export function useMetronome(options: MetronomeOptions = {}) {
beatIndex.value = 0
}
/**
*
*/
const updateAudioSrc = (newClickSrc: string, newAccentSrc?: string) => {
const wasPlaying = isPlaying.value
if (wasPlaying) stop()
// 销毁旧的音频池
clickPool?.destroy()
accentPool?.destroy()
clickPool = null
accentPool = null
// 创建新的音频池(静默预热,不播放)
clickPool = new AudioPool(newClickSrc, poolSize)
if (newAccentSrc) {
accentPool = new AudioPool(newAccentSrc, Math.max(2, Math.ceil(poolSize / 2)))
}
// 播放一次预览音效(适中音量)
const previewCtx = uni.createInnerAudioContext()
previewCtx.src = newClickSrc
previewCtx.obeyMuteSwitch = false
previewCtx.volume = 0.4
try {
previewCtx.play()
setTimeout(() => {
try {
previewCtx.destroy?.()
} catch (_) {}
}, 500)
} catch (_) {}
// 恢复播放状态
if (wasPlaying) start()
}
/**
* onShow
* setInnerAudioOption, App.vue (iOS )
@@ -214,6 +272,7 @@ export function useMetronome(options: MetronomeOptions = {}) {
stop,
setBpm,
setAccentEvery,
updateAudioSrc,
preload,
}
}
@@ -1,213 +0,0 @@
import { ref, onUnmounted } from 'vue'
/**
* - BackgroundAudioManager
*
* InnerAudioContext iOS :
* - iOS / InnerAudioContext ,requiredBackgroundModes
* - BackgroundAudioManager iOS / API
*
* :
* - BgAudio ,, click
* - ffmpeg 3 "完整节拍循环"(80/110/130 BPM × 2 )
* mp3 5~6 , onEnded src
*
* :
* - /+()
* - requiredBackgroundModes:["audio"]( manifest+pages)
* - 200~500ms ,
*/
export type LoopId = 'slow' | 'normal' | 'brisk'
export interface LoopPreset {
id: LoopId
bpm: number
accent: number
src: string
label: string
}
/**
* 重要:微信 BackgroundAudioManager.src http/https ,
* CDN( COS), downloadFile
*
* 线(2026-05-26 cos.ap-guangzhou):
* - loop_80bpm_2.mp3
* - loop_110bpm_2.mp3
* - loop_130bpm_2.mp3
*
* ( training/static/audio/*.mp3) ,
* , COS ffmpeg
*/
export const LOOP_PRESETS: readonly LoopPreset[] = [
{
id: 'slow',
bpm: 80,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3',
label: '慢走 80 BPM',
},
{
id: 'normal',
bpm: 110,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3',
label: '健走 110 BPM',
},
{
id: 'brisk',
bpm: 130,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3',
label: '快走 130 BPM',
},
]
/**
*
* ,, COS
* 注意:URL https, downloadFile
*/
const COVER_URL = ''
export function useMetronomeBg() {
const isPlaying = ref<boolean>(false)
const currentLoop = ref<LoopId | null>(null)
/* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */
let bgm: any = null
/* 当前期望的 src,onEnded 时用它重新赋值实现循环 */
let desiredSrc = ''
/* BackgroundAudioManager +
BgAudio ,, */
const ensureBgm = () => {
if (bgm) return bgm
const m = uni.getBackgroundAudioManager()
/* 必填 metadata,缺一会报错或不显示锁屏控制条 */
m.title = '节拍器'
m.epname = '甄养堂 · 健走'
m.singer = '健走配速'
if (COVER_URL) m.coverImgUrl = COVER_URL
m.webUrl = ''
m.onPlay(() => {
isPlaying.value = true
})
m.onPause(() => {
/* 用户从锁屏控制条点暂停,同步 UI 状态 */
isPlaying.value = false
})
m.onStop(() => {
isPlaying.value = false
currentLoop.value = null
})
/* : mp3 src
BgAudio loop , */
m.onEnded(() => {
if (desiredSrc && isPlaying.value) {
try {
m.src = desiredSrc
} catch (_) {}
}
})
m.onError((err) => {
console.error('[BgAudio] error:', err)
isPlaying.value = false
uni.showToast({
title: '音频播放失败,请重试',
icon: 'none',
duration: 2000,
})
})
bgm = m
return m
}
/**
*
* pause/play
* src( 200~500ms )
*/
const playLoop = (id: LoopId) => {
const preset = LOOP_PRESETS.find((p) => p.id === id)
if (!preset) return
const m = ensureBgm()
/* 同档位再点一下 = 暂停 */
if (currentLoop.value === id && isPlaying.value) {
m.pause()
return
}
/* 切换档位或从暂停恢复 */
currentLoop.value = id
desiredSrc = preset.src
/* 重设 title 让锁屏控制条显示当前档位 */
m.title = `节拍器 · ${preset.label}`
/* 赋值 src 会自动播放(微信 API 设计如此) */
m.src = preset.src
/* isPlaying 由 onPlay 回调置 true */
}
const pause = () => {
if (bgm && isPlaying.value) {
bgm.pause()
}
}
const resume = () => {
if (bgm && !isPlaying.value && desiredSrc) {
/* 从暂停态恢复:直接 play 即可 */
try {
bgm.play()
} catch (_) {
/* 部分基础库 play() 不可用时,重赋 src */
bgm.src = desiredSrc
}
}
}
/**
* +
* BgAudio ,stop
*/
const stop = () => {
if (bgm) {
try {
bgm.stop()
} catch (_) {}
}
currentLoop.value = null
desiredSrc = ''
isPlaying.value = false
}
/* hook stop,
()
metronome.vue 退 */
onUnmounted(() => {
/* ? BgAudio ,,
, isPlaying,
ensureBgm , */
})
return {
isPlaying,
currentLoop,
playLoop,
pause,
resume,
stop,
LOOP_PRESETS,
}
}
@@ -1,115 +0,0 @@
import { ref, onUnmounted } from 'vue'
const BGM_BASE = '/training/static/bgm'
export type BgmMode = 'none' | 'training' | 'resting'
export interface UseTrainingBgmOptions {
trainingSrc?: string
restingSrc?: string
volume?: number
fadeMs?: number
}
export function useTrainingBgm(options: UseTrainingBgmOptions = {}) {
const {
trainingSrc = `${BGM_BASE}/train-light.mp3`,
restingSrc = `${BGM_BASE}/rest-meditation.mp3`,
volume = 0.6,
fadeMs = 600,
} = options
const mode = ref<BgmMode>('none')
const enabled = ref<boolean>(true)
let trainCtx: UniApp.InnerAudioContext | null = null
let restCtx: UniApp.InnerAudioContext | null = null
let fadeTimers: ReturnType<typeof setInterval>[] = []
const ensureCtx = () => {
if (!trainCtx) {
trainCtx = uni.createInnerAudioContext()
trainCtx.src = trainingSrc
trainCtx.loop = true
trainCtx.obeyMuteSwitch = false
trainCtx.volume = 0
}
if (!restCtx) {
restCtx = uni.createInnerAudioContext()
restCtx.src = restingSrc
restCtx.loop = true
restCtx.obeyMuteSwitch = false
restCtx.volume = 0
}
}
const fade = (
ctx: UniApp.InnerAudioContext,
from: number,
to: number,
duration = fadeMs,
) => {
const steps = 16
const stepMs = Math.max(16, duration / steps)
let i = 0
const t = setInterval(() => {
i++
const v = from + (to - from) * (i / steps)
ctx.volume = Math.max(0, Math.min(1, v))
if (i >= steps) {
clearInterval(t)
fadeTimers = fadeTimers.filter((x) => x !== t)
}
}, stepMs)
fadeTimers.push(t)
}
const switchTo = (target: BgmMode) => {
if (!enabled.value) return
if (mode.value === target) return
ensureCtx()
if (target === 'training' && trainCtx && restCtx) {
fade(restCtx, restCtx.volume, 0)
setTimeout(() => restCtx?.pause(), fadeMs)
trainCtx.play()
fade(trainCtx, 0, volume)
} else if (target === 'resting' && trainCtx && restCtx) {
fade(trainCtx, trainCtx.volume, 0)
setTimeout(() => trainCtx?.pause(), fadeMs)
restCtx.play()
fade(restCtx, 0, volume)
} else if (target === 'none') {
if (trainCtx) {
fade(trainCtx, trainCtx.volume, 0)
setTimeout(() => trainCtx?.pause(), fadeMs)
}
if (restCtx) {
fade(restCtx, restCtx.volume, 0)
setTimeout(() => restCtx?.pause(), fadeMs)
}
}
mode.value = target
}
const setEnabled = (v: boolean) => {
enabled.value = v
if (!v) switchTo('none')
}
onUnmounted(() => {
fadeTimers.forEach((t) => clearInterval(t))
fadeTimers = []
trainCtx?.destroy?.()
restCtx?.destroy?.()
trainCtx = null
restCtx = null
})
return {
mode,
enabled,
switchTo,
setEnabled,
}
}
@@ -1,133 +0,0 @@
import { ref, computed, onUnmounted } from 'vue'
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
export interface TrainingSessionConfig {
sets: number
reps: number
restSec: number
beatsPerRep?: number
onRepComplete?: (currentRep: number, totalReps: number) => void
onSetComplete?: (currentSet: number, totalSets: number) => void
onEnterRest?: (restSec: number) => void
onExitRest?: () => void
onDone?: () => void
onCountdownTick?: (remainingSec: number) => void
}
export function useTrainingSession() {
const phase = ref<SessionPhase>('idle')
const currentSet = ref<number>(0)
const currentRep = ref<number>(0)
const restRemainingSec = ref<number>(0)
const beatCounter = ref<number>(0)
let config: TrainingSessionConfig | null = null
let restTimer: ReturnType<typeof setInterval> | null = null
const totalReps = computed(() => config?.reps ?? 0)
const totalSets = computed(() => config?.sets ?? 0)
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
const isTraining = computed(() => phase.value === 'training')
const isResting = computed(() => phase.value === 'resting')
const isDone = computed(() => phase.value === 'done')
const start = (cfg: TrainingSessionConfig) => {
config = cfg
phase.value = 'training'
currentSet.value = 1
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
}
const onBeat = () => {
if (phase.value !== 'training' || !config) return
beatCounter.value++
if (beatCounter.value % beatsPerRep.value !== 0) return
currentRep.value++
config.onRepComplete?.(currentRep.value, totalReps.value)
if (currentRep.value >= totalReps.value) {
config.onSetComplete?.(currentSet.value, totalSets.value)
if (currentSet.value >= totalSets.value) {
phase.value = 'done'
config.onDone?.()
return
}
enterRest()
}
}
const enterRest = () => {
if (!config) return
phase.value = 'resting'
restRemainingSec.value = config.restSec
config.onEnterRest?.(config.restSec)
restTimer = setInterval(() => {
restRemainingSec.value--
config?.onCountdownTick?.(restRemainingSec.value)
if (restRemainingSec.value <= 0) {
exitRest()
}
}, 1000)
}
const exitRest = () => {
if (restTimer) {
clearInterval(restTimer)
restTimer = null
}
if (!config) return
currentSet.value++
currentRep.value = 0
beatCounter.value = 0
phase.value = 'training'
config.onExitRest?.()
}
const skipRest = () => {
if (phase.value !== 'resting') return
exitRest()
}
const stop = () => {
if (restTimer) {
clearInterval(restTimer)
restTimer = null
}
phase.value = 'idle'
currentSet.value = 0
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
}
onUnmounted(() => {
if (restTimer) clearInterval(restTimer)
})
return {
phase,
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
start,
stop,
skipRest,
onBeat,
}
}
@@ -1,87 +0,0 @@
import { onUnmounted } from 'vue'
const VOICE_BASE = '/training/static/voice'
export type VoicePromptKey =
| 'start'
| 'ready'
| 'rest'
| 'next-set'
| 'last-rep'
| 'keep-it-up'
| 'good-job'
| 'breathe-in'
| 'breathe-out'
export interface UseVoiceCoachOptions {
enabled?: boolean
volume?: number
}
export function useVoiceCoach(options: UseVoiceCoachOptions = {}) {
const { enabled = true, volume = 1 } = options
let ctx: UniApp.InnerAudioContext | null = null
let queue: string[] = []
let isPlayingQueue = false
const ensureCtx = () => {
if (ctx) return
ctx = uni.createInnerAudioContext()
ctx.volume = volume
ctx.obeyMuteSwitch = false
ctx.onEnded(() => {
playNextInQueue()
})
ctx.onError(() => {
playNextInQueue()
})
}
const playNextInQueue = () => {
if (!ctx || queue.length === 0) {
isPlayingQueue = false
return
}
const nextSrc = queue.shift()!
ctx.src = nextSrc
ctx.play()
}
const enqueue = (src: string) => {
if (!enabled) return
ensureCtx()
queue.push(src)
if (!isPlayingQueue) {
isPlayingQueue = true
playNextInQueue()
}
}
const speakNumber = (n: number) => {
if (n < 1 || n > 30) return
enqueue(`${VOICE_BASE}/numbers/${n}.mp3`)
}
const speakPrompt = (key: VoicePromptKey) => {
enqueue(`${VOICE_BASE}/prompts/${key}.mp3`)
}
const stop = () => {
queue = []
isPlayingQueue = false
ctx?.stop()
}
onUnmounted(() => {
stop()
ctx?.destroy?.()
ctx = null
})
return {
speakNumber,
speakPrompt,
stop,
}
}
@@ -0,0 +1,675 @@
<template>
<view class="crush-canvas-container">
<canvas
type="2d"
id="crush"
class="crush-canvas"
></canvas>
</view>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
// ============================================================
//
// ============================================================
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
// ()
interface Debris {
x: number
y: number
vx: number
vy: number
size: number
color: string
shape: ParticleShape
rotation: number
rotationSpeed: number
life: number
decay: number
gravity: number
glow: boolean
swing: number // (confetti ),0
age: number
}
// (,)
interface Spark {
x: number
y: number
px: number // ()
py: number
vx: number
vy: number
size: number
color: string
life: number
decay: number
gravity: number
}
// ()
interface Sparkle {
x: number
y: number
size: number
rotation: number
spin: number
color: string
life: number
decay: number
}
//
interface Flash {
x: number
y: number
radius: number
maxRadius: number
color: string
life: number
decay: number
}
interface Shockwave {
x: number
y: number
radius: number
maxRadius: number
color: string
width: number
life: number
decay: number
}
interface CrushItemConfig {
colors: string[] //
sparkColors: string[] // /()
shapes: ParticleShape[]
debrisCount: number
sparkCount: number
sparkleCount: number
speedMin: number
speedMax: number
sizeMin: number
sizeMax: number
gravity: number
flashColor: string //
shockwaveColor: string
confetti?: boolean // balloon:
}
// ============================================================
//
// ============================================================
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
egg: {
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFD54F', '#FFC107'],
sparkColors: ['#FFFFFF', '#FFF9C4', '#FFEE58'],
shapes: ['circle', 'circle', 'circle'],
debrisCount: 20,
sparkCount: 16,
sparkleCount: 5,
speedMin: 3,
speedMax: 6,
sizeMin: 4,
sizeMax: 10,
gravity: 0.34,
flashColor: '#FFFDE7',
shockwaveColor: 'rgba(255, 224, 130, 0.85)',
},
walnut: {
colors: ['#5D4037', '#795548', '#8D6E63', '#A1887F', '#3E2723'],
sparkColors: ['#FFCC80', '#FFB74D', '#FFE0B2'],
shapes: ['square', 'triangle', 'rect'],
debrisCount: 22,
sparkCount: 18,
sparkleCount: 5,
speedMin: 2.8,
speedMax: 5,
sizeMin: 4,
sizeMax: 9,
gravity: 0.42,
flashColor: '#FFE0B2',
shockwaveColor: 'rgba(255, 167, 38, 0.8)',
},
can: {
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1', '#607D8B'],
sparkColors: ['#FFFFFF', '#E1F5FE', '#B3E5FC'],
shapes: ['rect', 'rect', 'triangle', 'square'],
debrisCount: 24,
sparkCount: 20,
sparkleCount: 6,
speedMin: 4,
speedMax: 7,
sizeMin: 3,
sizeMax: 11,
gravity: 0.36,
flashColor: '#FFFFFF',
shockwaveColor: 'rgba(207, 216, 220, 0.9)',
},
balloon: {
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE', '#FF6E40'],
sparkColors: ['#FFFFFF', '#FF80AB', '#82B1FF', '#FFFF8D'],
shapes: ['rect', 'rect', 'square'],
debrisCount: 28,
sparkCount: 18,
sparkleCount: 8,
speedMin: 4,
speedMax: 7.5,
sizeMin: 4,
sizeMax: 8,
gravity: 0.14,
flashColor: '#FCE4EC',
shockwaveColor: 'rgba(255, 64, 129, 0.85)',
confetti: true,
},
}
const PHYSICS = {
AIR_RESISTANCE: 0.985,
SPARK_RESISTANCE: 0.92,
DEBRIS_DECAY: 0.018,
SPARK_DECAY: 0.035,
SPARKLE_DECAY: 0.045,
FLASH_DECAY: 0.11,
SHOCKWAVE_DECAY: 0.045,
}
// ============================================================
//
// ============================================================
const canvasNode = ref<any>(null)
const ctx = ref<any>(null)
const canvasWidth = ref<number>(240)
const canvasHeight = ref<number>(240)
const dpr = ref<number>(1)
const rafId = ref<number | null>(null)
const renderRunning = ref<boolean>(false)
const debrisList = ref<Debris[]>([])
const sparkList = ref<Spark[]>([])
const sparkleList = ref<Sparkle[]>([])
const flashList = ref<Flash[]>([])
const shockwaves = ref<Shockwave[]>([])
const instance = getCurrentInstance()
// prop ( ref , )
const props = defineProps<{
crushSignal?: { type: CrushItemType; nonce: number } | null
}>()
watch(
() => props.crushSignal,
(sig) => {
if (sig) triggerCrush(sig.type)
},
)
onMounted(() => {
initCanvas()
})
onBeforeUnmount(() => {
cleanup()
})
// ============================================================
// Canvas 2D
// ============================================================
function initCanvas() {
setTimeout(() => {
if (!instance) return
const query = uni.createSelectorQuery().in(instance)
query.select('#crush')
.fields({ node: true, size: true } as any)
.exec((res: any[]) => {
if (!res || !res[0] || !res[0].node) {
// : node(),
console.warn('[crush-canvas] canvas 2d not supported on this platform')
return
}
const canvas = res[0].node
const width = res[0].width
const height = res[0].height
//
const systemInfo = uni.getSystemInfoSync()
const pixelRatio = systemInfo.pixelRatio || 1
// canvas ()
canvas.width = width * pixelRatio
canvas.height = height * pixelRatio
// 2D
const context = canvas.getContext('2d')
context.scale(pixelRatio, pixelRatio)
//
canvasNode.value = canvas
ctx.value = context
canvasWidth.value = width
canvasHeight.value = height
dpr.value = pixelRatio
})
}, 200)
}
// ============================================================
//
// ============================================================
function triggerCrush(itemType: CrushItemType) {
if (!ctx.value || !canvasNode.value) return
const item = CRUSH_ITEMS[itemType]
if (!item) return
const centerX = canvasWidth.value / 2
const centerY = canvasHeight.value / 2
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
// 1.
flashList.value.push({
x: centerX,
y: centerY,
radius: maxR * 0.12,
maxRadius: maxR * 0.62,
color: item.flashColor,
life: 1.0,
decay: PHYSICS.FLASH_DECAY,
})
// 2.
shockwaves.value.push({
x: centerX, y: centerY,
radius: 12, maxRadius: maxR * 0.62,
color: item.shockwaveColor, width: 5,
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
})
shockwaves.value.push({
x: centerX, y: centerY,
radius: 4, maxRadius: maxR * 0.42,
color: item.shockwaveColor, width: 3,
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
})
// 3. ()
for (let i = 0; i < item.debrisCount; i++) {
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
// confetti(): +
const confetti = !!item.confetti
const vy0 = confetti
? Math.sin(angle) * speed - 2.5 //
: Math.sin(angle) * speed
debrisList.value.push({
x: centerX,
y: centerY,
vx: Math.cos(angle) * speed,
vy: vy0,
size,
color,
shape,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
life: 1.0,
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
gravity: item.gravity,
glow: !confetti, // ,
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
age: Math.random() * Math.PI * 2,
})
}
// 4. (,)
for (let i = 0; i < item.sparkCount; i++) {
const angle = Math.random() * Math.PI * 2
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
sparkList.value.push({
x: centerX,
y: centerY,
px: centerX,
py: centerY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 1.5 + Math.random() * 2,
color,
life: 1.0,
decay: PHYSICS.SPARK_DECAY * (0.8 + Math.random() * 0.6),
gravity: item.gravity * 0.3,
})
}
// 5.
for (let i = 0; i < item.sparkleCount; i++) {
const r = maxR * (0.08 + Math.random() * 0.32)
const a = Math.random() * Math.PI * 2
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
sparkleList.value.push({
x: centerX + Math.cos(a) * r,
y: centerY + Math.sin(a) * r,
size: 10 + Math.random() * 16,
rotation: Math.random() * Math.PI,
spin: (Math.random() - 0.5) * 0.16,
color,
life: 1.0 + Math.random() * 0.4, //
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
})
}
ensureRenderLoop()
}
// ============================================================
//
// ============================================================
function ensureRenderLoop() {
if (renderRunning.value) return
renderRunning.value = true
renderLoop()
}
function renderLoop() {
if (!canvasNode.value || !ctx.value) {
renderRunning.value = false
return
}
const w = canvasWidth.value
const h = canvasHeight.value
//
for (let i = debrisList.value.length - 1; i >= 0; i--) {
const p = debrisList.value[i]
p.vy += p.gravity
p.vx *= PHYSICS.AIR_RESISTANCE
p.vy *= PHYSICS.AIR_RESISTANCE
p.x += p.vx
p.y += p.vy
p.age += 0.2
if (p.swing > 0) p.x += Math.sin(p.age) * p.swing //
p.rotation += p.rotationSpeed
p.life -= p.decay
if (p.life <= 0 || p.x < -60 || p.x > w + 60 || p.y > h + 60) {
debrisList.value.splice(i, 1)
}
}
//
for (let i = sparkList.value.length - 1; i >= 0; i--) {
const s = sparkList.value[i]
s.px = s.x
s.py = s.y
s.vy += s.gravity
s.vx *= PHYSICS.SPARK_RESISTANCE
s.vy *= PHYSICS.SPARK_RESISTANCE
s.x += s.vx
s.y += s.vy
s.life -= s.decay
if (s.life <= 0) sparkList.value.splice(i, 1)
}
//
for (let i = sparkleList.value.length - 1; i >= 0; i--) {
const sp = sparkleList.value[i]
sp.rotation += sp.spin
sp.life -= sp.decay
if (sp.life <= 0) sparkleList.value.splice(i, 1)
}
//
for (let i = flashList.value.length - 1; i >= 0; i--) {
const f = flashList.value[i]
f.radius += (f.maxRadius - f.radius) * 0.35
f.life -= f.decay
if (f.life <= 0) flashList.value.splice(i, 1)
}
//
for (let i = shockwaves.value.length - 1; i >= 0; i--) {
const sw = shockwaves.value[i]
sw.radius += (sw.maxRadius - sw.radius) * 0.16
sw.life -= sw.decay
if (sw.life <= 0) shockwaves.value.splice(i, 1)
}
draw()
const alive =
debrisList.value.length > 0 ||
sparkList.value.length > 0 ||
sparkleList.value.length > 0 ||
flashList.value.length > 0 ||
shockwaves.value.length > 0
if (alive) {
rafId.value = canvasNode.value.requestAnimationFrame(renderLoop) as unknown as number
} else {
rafId.value = null
renderRunning.value = false
}
}
// ============================================================
//
// ============================================================
function draw() {
const c = ctx.value
const w = canvasWidth.value
const h = canvasHeight.value
c.clearRect(0, 0, w, h)
// 1. ()
c.globalCompositeOperation = 'source-over'
shockwaves.value.forEach(sw => {
c.beginPath()
c.arc(sw.x, sw.y, sw.radius, 0, Math.PI * 2)
c.strokeStyle = applyAlphaToRgba(sw.color, Math.min(1, sw.life))
c.lineWidth = sw.width * sw.life
c.stroke()
})
// 2. ()
c.globalCompositeOperation = 'lighter'
flashList.value.forEach(f => {
const grd = c.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.radius)
const a = Math.min(1, f.life)
grd.addColorStop(0, hexToRgba(f.color, 0.95 * a))
grd.addColorStop(0.4, hexToRgba(f.color, 0.5 * a))
grd.addColorStop(1, hexToRgba(f.color, 0))
c.fillStyle = grd
c.beginPath()
c.arc(f.x, f.y, f.radius, 0, Math.PI * 2)
c.fill()
})
// 3. (,)
c.globalCompositeOperation = 'source-over'
debrisList.value.forEach(p => {
const alpha = Math.min(1, p.life)
const currentSize = p.size * (0.65 + p.life * 0.35)
// :
if (p.glow) {
c.fillStyle = hexToRgba(p.color, alpha * 0.22)
c.beginPath()
c.arc(p.x, p.y, currentSize * 1.9, 0, Math.PI * 2)
c.fill()
}
c.fillStyle = hexToRgba(p.color, alpha)
drawDebrisShape(c, p, currentSize)
})
// 4. ( + ,)
c.globalCompositeOperation = 'lighter'
sparkList.value.forEach(s => {
const a = Math.min(1, s.life)
// 线
c.strokeStyle = hexToRgba(s.color, a * 0.8)
c.lineWidth = s.size * a
c.lineCap = 'round'
c.beginPath()
c.moveTo(s.px, s.py)
c.lineTo(s.x, s.y)
c.stroke()
//
c.fillStyle = hexToRgba(s.color, a)
c.beginPath()
c.arc(s.x, s.y, s.size * a, 0, Math.PI * 2)
c.fill()
})
// 5. (,)
sparkleList.value.forEach(sp => {
// life 10, sin ""
const t = Math.max(0, Math.min(1, sp.life))
const scale = Math.sin(Math.min(1, sp.life) * Math.PI) // 010
if (scale <= 0.02) return
const a = t
drawStar(c, sp.x, sp.y, sp.size * scale, sp.size * scale * 0.32, sp.rotation, hexToRgba(sp.color, a))
})
c.globalCompositeOperation = 'source-over'
}
function drawDebrisShape(c: any, p: Debris, size: number) {
switch (p.shape) {
case 'circle':
c.beginPath()
c.arc(p.x, p.y, size, 0, Math.PI * 2)
c.fill()
break
case 'square':
c.save()
c.translate(p.x, p.y)
c.rotate(p.rotation)
c.fillRect(-size, -size, size * 2, size * 2)
c.restore()
break
case 'rect':
c.save()
c.translate(p.x, p.y)
c.rotate(p.rotation)
c.fillRect(-size * 1.6, -size * 0.5, size * 3.2, size)
c.restore()
break
case 'triangle':
c.save()
c.translate(p.x, p.y)
c.rotate(p.rotation)
c.beginPath()
c.moveTo(0, -size)
c.lineTo(size, size)
c.lineTo(-size, size)
c.closePath()
c.fill()
c.restore()
break
}
}
// (),
function drawStar(c: any, cx: number, cy: number, outerR: number, innerR: number, rotation: number, fill: string) {
const points = 4
c.save()
c.translate(cx, cy)
c.rotate(rotation)
c.beginPath()
for (let i = 0; i < points * 2; i++) {
const r = i % 2 === 0 ? outerR : innerR
const a = (Math.PI * i) / points
const x = Math.cos(a) * r
const y = Math.sin(a) * r
if (i === 0) c.moveTo(x, y)
else c.lineTo(x, y)
}
c.closePath()
c.fillStyle = fill
c.fill()
c.restore()
}
// ============================================================
//
// ============================================================
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function applyAlphaToRgba(rgba: string, alphaMultiplier: number): string {
const match = rgba.match(/rgba?\(([^)]+)\)/)
if (!match) return rgba
const parts = match[1].split(',').map(s => s.trim())
const r = parts[0]
const g = parts[1]
const b = parts[2]
const a = parts[3] ? parseFloat(parts[3]) : 1
return `rgba(${r}, ${g}, ${b}, ${a * alphaMultiplier})`
}
// ============================================================
//
// ============================================================
function cleanup() {
if (rafId.value !== null && canvasNode.value) {
try {
canvasNode.value.cancelAnimationFrame(rafId.value)
} catch (_) {}
rafId.value = null
}
renderRunning.value = false
debrisList.value = []
sparkList.value = []
sparkleList.value = []
flashList.value = []
shockwaves.value = []
}
</script>
<style scoped>
.crush-canvas-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.crush-canvas {
display: block;
width: 100%;
height: 100%;
background: transparent;
}
</style>
@@ -1,259 +0,0 @@
<template>
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
<view class="stage">
<!-- 哑铃弯举摆臂 -->
<view v-if="animationType === 'curl'" class="curl-arm">
<view class="curl-forearm">
<view class="curl-dumbbell">{{ icon }}</view>
</view>
</view>
<!-- 哑铃推举上下移动 -->
<view v-else-if="animationType === 'press'" class="press-wrap">
<view class="press-dumbbell left">{{ icon }}</view>
<view class="press-dumbbell right">{{ icon }}</view>
</view>
<!-- 哑铃侧平举双臂张开 -->
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
<view class="sidefly-arm sidefly-left">
<view class="sidefly-dumbbell">{{ icon }}</view>
</view>
<view class="sidefly-arm sidefly-right">
<view class="sidefly-dumbbell">{{ icon }}</view>
</view>
</view>
<!-- 握力环缩放 -->
<view v-else-if="animationType === 'grip'" class="grip-ring">
<view class="grip-ring-inner">{{ icon }}</view>
</view>
<!-- 卷腹身体折叠 -->
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
<view class="crunch-body">{{ icon }}</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AnimationType } from '../exercises'
interface Props {
animationType: AnimationType
bpm: number
beatsPerRep?: number
icon?: string
isPlaying?: boolean
}
const props = withDefaults(defineProps<Props>(), {
beatsPerRep: 2,
icon: '🏋',
isPlaying: false,
})
const cssVars = computed(() => {
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
return {
'--rep-duration': `${repDurationMs}ms`,
'--anim-state': props.isPlaying ? 'running' : 'paused',
} as Record<string, string>
})
</script>
<style lang="scss" scoped>
.exercise-anim {
width: 100%;
height: 480rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
border-radius: 24rpx;
overflow: hidden;
}
.stage {
width: 320rpx;
height: 320rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* ===== 弯举 ===== */
.curl-arm {
width: 60rpx;
height: 240rpx;
background: #fbbf24;
border-radius: 30rpx;
position: relative;
.curl-forearm {
width: 60rpx;
height: 200rpx;
background: #f59e0b;
border-radius: 30rpx;
position: absolute;
bottom: 0;
left: 0;
transform-origin: bottom center;
animation: curl-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
}
.curl-dumbbell {
position: absolute;
top: -20rpx;
left: 50%;
transform: translateX(-50%);
font-size: 56rpx;
}
}
@keyframes curl-anim {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-115deg);
}
}
/* ===== 推举 ===== */
.press-wrap {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0 20rpx;
}
.press-dumbbell {
font-size: 88rpx;
animation: press-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
}
@keyframes press-anim {
0%,
100% {
transform: translateY(80rpx);
}
50% {
transform: translateY(-80rpx);
}
}
/* ===== 侧平举 ===== */
.sidefly-wrap {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.sidefly-arm {
position: absolute;
width: 140rpx;
height: 24rpx;
background: #f59e0b;
border-radius: 12rpx;
top: 50%;
transform-origin: center right;
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
&.sidefly-left {
right: 50%;
}
&.sidefly-right {
left: 50%;
transform-origin: center left;
animation-name: sidefly-right;
}
}
.sidefly-dumbbell {
position: absolute;
top: -36rpx;
font-size: 56rpx;
}
.sidefly-left .sidefly-dumbbell {
left: -20rpx;
}
.sidefly-right .sidefly-dumbbell {
right: -20rpx;
}
@keyframes sidefly-left {
0%,
100% {
transform: rotate(80deg);
}
50% {
transform: rotate(0deg);
}
}
@keyframes sidefly-right {
0%,
100% {
transform: rotate(-80deg);
}
50% {
transform: rotate(0deg);
}
}
/* ===== 握力环 ===== */
.grip-ring {
width: 240rpx;
height: 240rpx;
animation: grip-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
border: 16rpx solid #22c55e;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(34, 197, 94, 0.1);
}
.grip-ring-inner {
font-size: 80rpx;
}
@keyframes grip-anim {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(0.65);
}
}
/* ===== 卷腹 ===== */
.crunch-wrap {
width: 280rpx;
height: 280rpx;
display: flex;
align-items: flex-end;
justify-content: center;
}
.crunch-body {
font-size: 120rpx;
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
transform-origin: bottom center;
}
@keyframes crunch-anim {
0%,
100% {
transform: scaleY(1) translateY(0);
}
50% {
transform: scaleY(0.6) translateY(-10rpx);
}
}
</style>
@@ -0,0 +1,383 @@
<template>
<view class="walker-canvas-container">
<canvas
canvas-id="walker"
id="walker"
class="walker-canvas"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchCancel"
></canvas>
</view>
</template>
<script>
export default {
props: {
isPlaying: {
type: Boolean,
default: false
},
bpm: {
type: Number,
default: 110
}
},
data() {
return {
canvasWidth: 320,
canvasHeight: 400,
ctx: null,
timer: null,
renderRunning: false,
// Interaction State (Spring Physics)
coreScale: 1,
coreTargetScale: 1,
coreVelocity: 0,
pressed: false,
// Animation Physics (Time)
time: 0,
lastFrameTime: 0,
// Metronome State
lastBeatTime: 0,
// Energy Waves
waveAmplitudeMultiplier: 1,
// Shockwaves
shockwaves: [],
// Rotation for outer ring
ringAngle: 0
};
},
mounted() {
this.initCanvas();
},
beforeUnmount() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.renderRunning = false;
},
watch: {
isPlaying(newVal) {
if (newVal) {
this.waveAmplitudeMultiplier = 1;
this.ensureRenderLoop();
}
}
},
methods: {
initCanvas() {
// Delay to ensure Flexbox layout is fully complete before measuring
setTimeout(() => {
const query = uni.createSelectorQuery().in(this);
query.select('.walker-canvas-container').boundingClientRect(data => {
if (data && data.width > 0) {
this.canvasWidth = data.width;
this.canvasHeight = data.height > 100 ? data.height : data.width * 1.2;
} else {
const sys = uni.getSystemInfoSync();
this.canvasWidth = sys.windowWidth - 30; // approximate padding
this.canvasHeight = this.canvasWidth * 1.2;
}
if (!this.ctx) {
this.ctx = uni.createCanvasContext('walker', this);
this.lastFrameTime = Date.now();
this.ensureRenderLoop();
}
}).exec();
}, 100);
},
// Expose a method to be called from parent when a beat hits
triggerBeat() {
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
// Add a shockwave
this.shockwaves.push({
radius: coreRadius,
maxRadius: coreRadius * 2.2,
opacity: 1
});
// Spike the wave amplitude
this.waveAmplitudeMultiplier = 2.5;
// Slight pulse to the core
this.coreVelocity -= 0.05;
this.ensureRenderLoop();
},
onTouchStart(e) {
const touch = e.touches[0];
if (!touch) return;
// Center
const cx = this.canvasWidth / 2;
const cy = this.canvasHeight / 2 - 30;
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
const dx = touch.x - cx;
const dy = touch.y - cy;
const dist = Math.sqrt(dx*dx + dy*dy);
// Hit box slightly larger than the visual circle
if (dist <= coreRadius + 40) {
this.pressed = true;
this.coreTargetScale = 0.9;
this.ensureRenderLoop();
}
},
onTouchMove(e) {
if (!this.pressed) return;
const touch = e.touches[0];
if (!touch) return;
const cx = this.canvasWidth / 2;
const cy = this.canvasHeight / 2 - 30;
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
const dx = touch.x - cx;
const dy = touch.y - cy;
const dist = Math.sqrt(dx*dx + dy*dy);
// Slid out of the hit box - cancel the press
if (dist > coreRadius + 40) {
this.pressed = false;
this.coreTargetScale = 1;
}
},
onTouchEnd() {
// Unconditionally reset the visual scale
this.coreTargetScale = 1;
// Only emit toggle when the press has not been cancelled by drag-out
if (this.pressed) {
this.$emit('toggle-play');
}
this.pressed = false;
},
onTouchCancel() {
// System interruption (incoming call, notification pull-down, etc.):
// reset visuals only do NOT count this as a user toggle intent.
this.coreTargetScale = 1;
this.pressed = false;
},
ensureRenderLoop() {
if (this.renderRunning) return;
this.renderRunning = true;
this.renderLoop();
},
renderLoop() {
// Use a fixed time delta to prevent rubber-banding/jitter on unstable JS timers
const fixedDt = 0.016;
if (this.isPlaying) {
this.time += fixedDt;
} else {
this.time += fixedDt * 0.2; // slow drift when paused
}
// Spring Physics for Core Scale
const tension = 120;
const friction = 12;
const force = (this.coreTargetScale - this.coreScale) * tension;
this.coreVelocity += force * fixedDt;
this.coreVelocity *= Math.exp(-friction * fixedDt);
this.coreScale += this.coreVelocity * fixedDt;
// Decay wave amplitude back to 1
if (this.waveAmplitudeMultiplier > 1) {
this.waveAmplitudeMultiplier -= fixedDt * 3;
if (this.waveAmplitudeMultiplier < 1) this.waveAmplitudeMultiplier = 1;
} else if (!this.isPlaying && this.waveAmplitudeMultiplier > 0.1) {
this.waveAmplitudeMultiplier -= fixedDt * 2;
if (this.waveAmplitudeMultiplier < 0.1) this.waveAmplitudeMultiplier = 0.1;
}
this.draw();
this.ctx.draw(false);
// Decide whether to continue the render loop. Short-circuit on isPlaying first for perf.
const shouldContinue =
this.isPlaying ||
this.shockwaves.length > 0 ||
Math.abs(this.coreScale - this.coreTargetScale) >= 0.001 ||
Math.abs(this.coreVelocity) >= 0.001 ||
this.waveAmplitudeMultiplier > 0.11;
if (shouldContinue) {
// Polyfill requestAnimationFrame for uni-app
this.timer = setTimeout(() => {
this.renderLoop();
}, 1000 / 60);
} else {
this.timer = null;
this.renderRunning = false;
}
},
draw() {
const ctx = this.ctx;
const W = this.canvasWidth;
const H = this.canvasHeight;
// Clear entire canvas to ensure it is completely transparent and blends with the page background
ctx.clearRect(0, 0, W, H);
// Dynamic majestic sizing to fill the huge screen area
const coreRadius = Math.min(W, H) * 0.32; // Make the central circle MASSIVE
const ringRadius = coreRadius + 18;
const cx = W / 2;
const cy = H / 2 - 30; // Slightly above center
// 3. Draw Shockwaves (Ripples)
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
const sw = this.shockwaves[i];
sw.radius += (sw.maxRadius - sw.radius) * 0.08;
sw.opacity -= 0.03;
if (sw.opacity <= 0) {
this.shockwaves.splice(i, 1);
continue;
}
ctx.beginPath();
ctx.arc(cx, cy, sw.radius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(16, 185, 129, ${sw.opacity * 0.5})`;
ctx.lineWidth = 3;
ctx.stroke();
}
// 4. Draw Energy Waves (Bottom area)
this.drawWaves(ctx, W, H);
// 5. Draw Interactive BPM Core
ctx.save();
ctx.translate(cx, cy);
ctx.scale(this.coreScale, this.coreScale);
// The Core Button (Emerald Gradient)
ctx.setShadow(0, 12, 24, 'rgba(16, 185, 129, 0.3)');
const coreGrad = ctx.createLinearGradient(-coreRadius, -coreRadius, coreRadius, coreRadius);
coreGrad.addColorStop(0, '#34d399'); // Light Mint
coreGrad.addColorStop(1, '#047857'); // Deep Emerald
ctx.beginPath();
ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);
ctx.fillStyle = coreGrad;
ctx.fill();
// Clear shadow for internal elements
ctx.setShadow(0, 0, 0, 'transparent');
// Inner Subtle Highlight (Glass edge)
ctx.beginPath();
ctx.arc(0, 0, coreRadius - 2, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
ctx.lineWidth = 2;
ctx.stroke();
// Text - BPM Number (MASSIVE font)
const fontSize = Math.floor(coreRadius * 0.7); // Dynamic font size based on radius
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${fontSize}px "DIN Condensed", "Inter", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Removed shadowBlur for text to save low-end GPU performance
ctx.fillText(this.bpm.toString(), 0, -12);
// Text - 'BPM' Label
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.font = '500 16px "Inter", sans-serif';
ctx.fillText('BPM', 0, coreRadius * 0.3);
// Play/Pause Icon
ctx.fillStyle = '#ffffff';
const iconY = coreRadius * 0.55;
if (this.isPlaying) {
// Pause Icon (Two vertical bars)
ctx.fillRect(-8, iconY - 6, 5, 14);
ctx.fillRect(3, iconY - 6, 5, 14);
} else {
// Play Icon (Triangle)
ctx.beginPath();
ctx.moveTo(-5, iconY - 8);
ctx.lineTo(9, iconY);
ctx.lineTo(-5, iconY + 8);
ctx.closePath();
ctx.fill();
}
ctx.restore();
},
drawWaves(ctx, W, H) {
const baseY = H * 0.82;
const dynamicAmp = H * 0.08;
// Reduced to 2 layers to save GPU/Bridge rendering time and ensure 60fps
const waves = [
{ color: 'rgba(52, 211, 153, 0.3)', speed: 1.5, freq: 0.012, amp: dynamicAmp * 0.7, offset: 0 },
{ color: 'rgba(16, 185, 129, 0.6)', speed: 2.5, freq: 0.015, amp: dynamicAmp, offset: Math.PI }
];
const activeAmp = this.waveAmplitudeMultiplier;
const t = this.time;
waves.forEach(wave => {
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, baseY);
// Increased step size to drastically reduce JS-to-Native bridge commands
const step = 25;
for (let x = 0; x <= W; x += step) {
// Removed edge envelope taper so waves crash cleanly into the edge of the screen
const y = baseY + Math.sin(x * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
ctx.lineTo(x, y);
}
// Add one final line to exactly W to ensure flush edge
const finalY = baseY + Math.sin(W * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
ctx.lineTo(W, finalY);
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = wave.color;
ctx.fill();
});
}
}
};
</script>
<style scoped>
.walker-canvas-container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.walker-canvas {
display: block;
width: 100%;
height: 100%;
background: transparent; /* Pure transparent to blend into page */
}
</style>
File diff suppressed because it is too large Load Diff
-116
View File
@@ -1,116 +0,0 @@
export type AnimationType =
| 'curl'
| 'press'
| 'sidefly'
| 'grip'
| 'crunch'
export interface ExercisePreset {
id: string
name: string
equipment: '哑铃' | '握力环' | '健身环' | '徒手'
icon: string
animationType: AnimationType
description: string
tips: string[]
contraindication?: string
defaultBpm: number
bpmMin: number
bpmMax: number
defaultSets: number
defaultReps: number
defaultRestSec: number
beatsPerRep: number
}
export const EXERCISE_PRESETS: ExercisePreset[] = [
{
id: 'dumbbell-curl',
name: '哑铃弯举',
equipment: '哑铃',
icon: '🏋',
animationType: 'curl',
description: '锻炼肱二头肌,注意肘部固定不动',
tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'],
contraindication: '肘关节炎症急性期不宜',
defaultBpm: 60,
bpmMin: 40,
bpmMax: 100,
defaultSets: 3,
defaultReps: 12,
defaultRestSec: 60,
beatsPerRep: 2,
},
{
id: 'dumbbell-press',
name: '哑铃推举',
equipment: '哑铃',
icon: '🏋',
animationType: 'press',
description: '锻炼肩部三角肌,核心保持收紧',
tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'],
contraindication: '肩袖损伤者请咨询医生',
defaultBpm: 60,
bpmMin: 40,
bpmMax: 90,
defaultSets: 3,
defaultReps: 10,
defaultRestSec: 90,
beatsPerRep: 2,
},
{
id: 'dumbbell-sidefly',
name: '哑铃侧平举',
equipment: '哑铃',
icon: '🏋',
animationType: 'sidefly',
description: '锻炼三角肌中束,重量宜轻不宜重',
tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'],
defaultBpm: 60,
bpmMin: 40,
bpmMax: 80,
defaultSets: 3,
defaultReps: 12,
defaultRestSec: 60,
beatsPerRep: 2,
},
{
id: 'grip-ring',
name: '握力环训练',
equipment: '握力环',
icon: '🟢',
animationType: 'grip',
description: '锻炼前臂屈肌群,改善握力',
tips: ['完全握紧停顿 1 秒', '完全张开放松', '左右手交替'],
defaultBpm: 80,
bpmMin: 60,
bpmMax: 120,
defaultSets: 4,
defaultReps: 20,
defaultRestSec: 45,
beatsPerRep: 2,
},
{
id: 'crunch',
name: '集中机(仰卧卷腹)',
equipment: '健身环',
icon: '💪',
animationType: 'crunch',
description: '锻炼腹直肌,借助健身环增加阻力',
tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'],
contraindication: '腰椎间盘突出急性期不宜',
defaultBpm: 50,
bpmMin: 40,
bpmMax: 80,
defaultSets: 3,
defaultReps: 15,
defaultRestSec: 60,
beatsPerRep: 2,
},
]
export function getPresetById(id: string): ExercisePreset | undefined {
return EXERCISE_PRESETS.find((e) => e.id === id)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-610
View File
@@ -1,610 +0,0 @@
<template>
<view class="training-page">
<view class="header">
<view class="header-main">
<text class="title">练一练</text>
<text class="subtitle">器械动作跟练</text>
</view>
<view class="header-link" @click="goMetronome">
🎵 节拍器
</view>
</view>
<view class="equipment-tabs">
<view
v-for="eq in equipmentList"
:key="eq"
class="equipment-tab"
:class="{ active: currentEquipment === eq }"
@click="onSwitchEquipment(eq)"
>
{{ eq }}
</view>
</view>
<scroll-view scroll-x class="exercise-scroll">
<view class="exercise-list">
<view
v-for="ex in filteredExercises"
:key="ex.id"
class="exercise-card"
:class="{ active: selectedId === ex.id }"
@click="onSelect(ex.id)"
>
<text class="exercise-icon">{{ ex.icon }}</text>
<text class="exercise-name">{{ ex.name }}</text>
</view>
</view>
</scroll-view>
<view v-if="selected" class="exercise-detail">
<exercise-anim
:animation-type="selected.animationType"
:bpm="bpm"
:beats-per-rep="selected.beatsPerRep"
:icon="selected.icon"
:is-playing="metronomeIsPlaying"
/>
<view class="beat-indicator">
<view
v-for="i in 4"
:key="i"
class="beat-dot"
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
/>
</view>
<view class="meta-row">
<text class="meta-label">{{ selected.description }}</text>
</view>
<view class="settings">
<view class="setting-row">
<text class="setting-label">节奏</text>
<slider
:value="bpm"
:min="selected.bpmMin"
:max="selected.bpmMax"
:step="2"
block-size="24"
active-color="#22c55e"
@change="onBpmChange"
/>
<text class="setting-value">{{ bpm }} BPM</text>
</view>
<view class="setting-row inline">
<view class="inline-item">
<text class="setting-label">组数</text>
<view class="stepper">
<text class="step-btn" @click="adjust('sets', -1)">-</text>
<text class="step-val">{{ sets }}</text>
<text class="step-btn" @click="adjust('sets', 1)">+</text>
</view>
</view>
<view class="inline-item">
<text class="setting-label">次数</text>
<view class="stepper">
<text class="step-btn" @click="adjust('reps', -1)">-</text>
<text class="step-val">{{ reps }}</text>
<text class="step-btn" @click="adjust('reps', 1)">+</text>
</view>
</view>
<view class="inline-item">
<text class="setting-label">休息()</text>
<view class="stepper">
<text class="step-btn" @click="adjust('rest', -15)">-</text>
<text class="step-val">{{ restSec }}</text>
<text class="step-btn" @click="adjust('rest', 15)">+</text>
</view>
</view>
</view>
</view>
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
<button class="btn-primary" @click="onStart">开始训练</button>
</view>
<view v-if="isTraining" class="status-card training">
<text class="status-title">训练中</text>
<text class="status-line"> {{ currentSet }} / {{ totalSets }} </text>
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
<view class="action-row">
<button class="btn-secondary" @click="onStop">停止</button>
</view>
</view>
<view v-if="isResting" class="status-card resting">
<text class="status-title">休息中</text>
<text class="status-line big">{{ restRemainingSec }}s</text>
<view class="action-row">
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
<button class="btn-text" @click="onStop">结束训练</button>
</view>
</view>
<view v-if="isDone" class="status-card done">
<text class="status-title">训练完成 🎉</text>
<text class="status-line"> {{ totalSets }} × {{ totalReps }} </text>
<view class="action-row">
<button class="btn-primary" @click="onStart">再来一次</button>
</view>
</view>
<view class="tips-card">
<text class="tips-title">动作要领</text>
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
{{ tip }}
</text>
<text v-if="selected.contraindication" class="tips-warn">
{{ selected.contraindication }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import ExerciseAnim from './components/exercise-anim.vue'
import { EXERCISE_PRESETS, getPresetById } from './exercises'
import type { ExercisePreset } from './exercises'
import { useMetronome } from '../hooks/useMetronome'
import { useTrainingSession } from '../hooks/useTrainingSession'
import { useVoiceCoach } from '../hooks/useVoiceCoach'
import { useTrainingBgm } from '../hooks/useTrainingBgm'
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
type Equipment = (typeof equipmentList)[number]
const currentEquipment = ref<Equipment>('哑铃')
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
const filteredExercises = computed(() =>
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
)
const selected = computed<ExercisePreset | undefined>(() =>
getPresetById(selectedId.value),
)
const bpm = ref<number>(60)
const sets = ref<number>(3)
const reps = ref<number>(12)
const restSec = ref<number>(60)
watch(
selected,
(val) => {
if (!val) return
bpm.value = val.defaultBpm
sets.value = val.defaultSets
reps.value = val.defaultReps
restSec.value = val.defaultRestSec
},
{ immediate: true },
)
const voice = useVoiceCoach()
const bgm = useTrainingBgm()
const session = useTrainingSession()
const {
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
} = session
const metronome = useMetronome({
initialBpm: bpm.value,
onBeat: () => session.onBeat(),
})
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
watch(bpm, (v) => metronome.setBpm(v))
const onSwitchEquipment = (eq: Equipment) => {
if (isTraining.value || isResting.value) return
currentEquipment.value = eq
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
if (first) selectedId.value = first.id
}
const onSelect = (id: string) => {
if (isTraining.value || isResting.value) return
selectedId.value = id
}
const onBpmChange = (e: any) => {
const v = e.detail?.value ?? e
bpm.value = Number(v)
}
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
if (isTraining.value || isResting.value) return
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
}
const onStart = () => {
if (!selected.value) return
uni.setKeepScreenOn({ keepScreenOn: true })
voice.speakPrompt('start')
session.start({
sets: sets.value,
reps: reps.value,
restSec: restSec.value,
beatsPerRep: selected.value.beatsPerRep,
onRepComplete: (rep, total) => {
if (rep <= 30) voice.speakNumber(rep)
if (rep === total - 1) voice.speakPrompt('last-rep')
},
onSetComplete: (set, total) => {
if (set < total) voice.speakPrompt('keep-it-up')
},
onEnterRest: () => {
metronome.stop()
voice.speakPrompt('rest')
bgm.switchTo('resting')
},
onExitRest: () => {
voice.speakPrompt('next-set')
bgm.switchTo('training')
metronome.start()
},
onDone: () => {
metronome.stop()
bgm.switchTo('none')
voice.speakPrompt('good-job')
uni.setKeepScreenOn({ keepScreenOn: false })
},
})
bgm.switchTo('training')
metronome.start()
}
const onStop = () => {
metronome.stop()
session.stop()
bgm.switchTo('none')
uni.setKeepScreenOn({ keepScreenOn: false })
}
const onSkipRest = () => {
session.skipRest()
}
const goMetronome = () => {
uni.navigateTo({ url: '/training/pages/metronome' })
}
onHide(() => {
if (isTraining.value || isResting.value) {
onStop()
}
})
onUnmounted(() => {
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
<style lang="scss" scoped>
.training-page {
min-height: 100vh;
background: #f9fafb;
padding: 32rpx 24rpx 80rpx;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.header-main {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
font-size: 44rpx;
font-weight: 700;
color: #111827;
}
.subtitle {
font-size: 26rpx;
color: #6b7280;
margin-top: 6rpx;
}
.header-link {
padding: 12rpx 24rpx;
background: #f3f4f6;
border-radius: 24rpx;
font-size: 24rpx;
color: #4b5563;
&:active {
background: #e5e7eb;
}
}
}
.equipment-tabs {
display: flex;
gap: 16rpx;
margin-bottom: 24rpx;
overflow-x: auto;
.equipment-tab {
flex-shrink: 0;
padding: 14rpx 32rpx;
background: #fff;
border-radius: 32rpx;
font-size: 28rpx;
color: #4b5563;
border: 2rpx solid transparent;
&.active {
background: #22c55e;
color: #fff;
border-color: #22c55e;
}
}
}
.exercise-scroll {
width: 100%;
margin-bottom: 24rpx;
}
.exercise-list {
display: flex;
gap: 16rpx;
padding-right: 16rpx;
}
.exercise-card {
flex-shrink: 0;
width: 160rpx;
padding: 20rpx 12rpx;
background: #fff;
border-radius: 16rpx;
text-align: center;
border: 2rpx solid transparent;
&.active {
border-color: #22c55e;
background: #f0fdf4;
}
.exercise-icon {
display: block;
font-size: 56rpx;
margin-bottom: 8rpx;
}
.exercise-name {
font-size: 24rpx;
color: #374151;
}
}
.exercise-detail {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.beat-indicator {
display: flex;
justify-content: center;
gap: 16rpx;
padding: 8rpx 0;
.beat-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #d1d5db;
transition: transform 0.15s, background 0.15s;
&.active {
background: #22c55e;
transform: scale(1.6);
}
}
}
.meta-row {
.meta-label {
font-size: 26rpx;
color: #6b7280;
text-align: center;
display: block;
}
}
.settings {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.setting-row {
display: flex;
align-items: center;
gap: 16rpx;
.setting-label {
font-size: 26rpx;
color: #4b5563;
min-width: 80rpx;
}
.setting-value {
font-size: 26rpx;
color: #22c55e;
font-weight: 600;
min-width: 120rpx;
text-align: right;
}
slider {
flex: 1;
}
&.inline {
justify-content: space-between;
gap: 8rpx;
}
}
.inline-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
}
.stepper {
display: flex;
align-items: center;
gap: 12rpx;
.step-btn {
width: 48rpx;
height: 48rpx;
line-height: 48rpx;
text-align: center;
background: #f3f4f6;
border-radius: 24rpx;
font-size: 32rpx;
color: #4b5563;
}
.step-val {
font-size: 30rpx;
font-weight: 600;
color: #111827;
min-width: 56rpx;
text-align: center;
}
}
.action-row {
display: flex;
gap: 16rpx;
justify-content: center;
button {
flex: 1;
max-width: 320rpx;
}
}
.btn-primary {
background: #22c55e;
color: #fff;
border-radius: 999rpx;
font-size: 30rpx;
height: 88rpx;
line-height: 88rpx;
border: none;
}
.btn-secondary {
background: #fff;
color: #22c55e;
border: 2rpx solid #22c55e;
border-radius: 999rpx;
font-size: 28rpx;
height: 80rpx;
line-height: 76rpx;
}
.btn-text {
background: transparent;
color: #ef4444;
font-size: 26rpx;
height: 80rpx;
line-height: 80rpx;
border: none;
}
.status-card {
background: #fff;
border-radius: 20rpx;
padding: 32rpx 24rpx;
text-align: center;
display: flex;
flex-direction: column;
gap: 12rpx;
align-items: center;
&.training {
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
}
&.resting {
background: linear-gradient(135deg, #fef3c7, #fef9c3);
}
&.done {
background: linear-gradient(135deg, #dbeafe, #ede9fe);
}
.status-title {
font-size: 30rpx;
color: #4b5563;
font-weight: 600;
}
.status-line {
font-size: 28rpx;
color: #6b7280;
&.big {
font-size: 64rpx;
color: #111827;
font-weight: 700;
}
}
}
.tips-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
.tips-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
margin-bottom: 8rpx;
}
.tips-item {
font-size: 26rpx;
color: #4b5563;
line-height: 1.6;
}
.tips-warn {
font-size: 26rpx;
color: #ef4444;
margin-top: 12rpx;
line-height: 1.6;
}
}
</style>
+214 -398
View File
@@ -1,46 +1,32 @@
<template>
<view class="page" :style="rootStyle">
<!-- ===== 主舞台:节拍器圆 ===== -->
<view class="stage" @click="onCenterTap">
<view class="ring r1" :class="{ playing: isPlaying }" />
<view class="ring r2" :class="{ playing: isPlaying }" />
<view class="core" :class="{ playing: isPlaying }">
<text class="bpm-num">{{ currentBpm }}</text>
<text class="bpm-label">BPM</text>
<view class="ctrl">
<view v-if="isPlaying" class="icon-pause">
<view class="bar" />
<view class="bar" />
</view>
<view v-else class="icon-play" />
</view>
</view>
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
<view class="stage-canvas-wrapper">
<WalkerCanvas
ref="walkerCanvasRef"
:bpm="currentBpm"
:is-playing="isPlaying"
@toggle-play="onCenterTap"
/>
</view>
<!-- ===== 跑步小人(矩形完整显示,跟白底融合) ===== -->
<view class="walker">
<view class="walker-box">
<video
id="runner-video"
class="runner-video"
:src="RUNNER_VIDEO_URL"
:muted="true"
:loop="true"
:autoplay="true"
:show-controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-center-play-btn="false"
:enable-progress-gesture="false"
:show-mute-btn="false"
:picture-in-picture-mode="[]"
object-fit="contain"
@error="onVideoError"
@loadedmetadata="onVideoLoaded"
/>
<!-- #ifdef MP-WEIXIN -->
<cover-view class="pip-mask">&nbsp;</cover-view>
<!-- #endif -->
<!-- ===== 音色选择 ===== -->
<view class="sound-selector">
<view
class="sound-option"
:class="{ active: currentSound === 'crisp' }"
@click="onSoundSelect('crisp')"
>
<text class="sound-icon"></text>
<text class="sound-label">清脆</text>
</view>
<view
class="sound-option"
:class="{ active: currentSound === 'wood' }"
@click="onSoundSelect('wood')"
>
<text class="sound-icon">🪵</text>
<text class="sound-label">木鱼</text>
</view>
</view>
@@ -50,278 +36,185 @@
v-for="p in LOOP_PRESETS"
:key="p.id"
class="preset"
:class="{ active: mode === 'preset' && currentLoop === p.id }"
:class="{ active: currentLoop === p.id }"
@click="onPresetTap(p.id)"
>
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
<text class="preset-name">{{ presetName(p.id) }}</text>
<text class="preset-name">{{ p.label }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ presetDesc(p.id) }}</text>
<text class="preset-desc">{{ p.desc }}</text>
</view>
</view>
<!-- ===== 自定义节奏(高级折叠区) ===== -->
<view v-if="customExpanded" class="custom-panel">
<view class="custom-warn">
<text class="warn-dot"></text>
<text class="warn-text">前台模式 · 切后台会暂停</text>
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
<view class="custom-section">
<!-- 标题栏(始终显示) -->
<view class="custom-header" @click="onToggleCustom">
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
</view>
<!-- BPM 微调 -->
<view class="row">
<text class="row-label">BPM</text>
<view class="bpm-stepper">
<view class="step-btn" @click="onBpmDelta(-5)">5</view>
<view class="step-btn" @click="onBpmDelta(-1)">1</view>
<view class="step-val">{{ customBpm }}</view>
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
<!-- 折叠内容(仅展开时显示) -->
<view v-if="customExpanded" class="custom-content">
<!-- BPM 微调 -->
<view class="row">
<text class="row-label">BPM</text>
<view class="bpm-stepper">
<view class="step-btn" @click="onBpmDelta(-5)">5</view>
<view class="step-btn" @click="onBpmDelta(-1)">1</view>
<view class="step-val">{{ currentBpm }}</view>
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
</view>
</view>
<!-- 拍号 -->
<view class="row">
<text class="row-label">拍号</text>
<view class="meter-tabs">
<view
v-for="n in [2, 3, 4]"
:key="n"
class="meter-tab"
:class="{ active: customAccent === n }"
@click="onAccentSelect(n)"
>{{ n }}/4</view>
</view>
</view>
</view>
<!-- 拍号 -->
<view class="row">
<text class="row-label">拍号</text>
<view class="meter-tabs">
<view
v-for="n in [2, 3, 4]"
:key="n"
class="meter-tab"
:class="{ active: customAccent === n }"
@click="onAccentSelect(n)"
>{{ n }}/4</view>
</view>
</view>
</view>
<!-- 底部行:自定义入口 + 提示 -->
<view class="bottom-row">
<text class="bottom-link" @click="onToggleCustom">
{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}
</text>
<text class="bottom-hint" v-if="mode === 'preset'">🔒 锁屏可继续播放</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
import { useMetronome } from '../hooks/useMetronome'
import WalkerCanvas from './components/walker-canvas.vue'
/**
* 跑步动画视频(腾讯云 COS CDN)
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
* 否则正式发布版会被拦截(开发版默认不校验)
*/
const RUNNER_VIDEO_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
type LoopId = 'slow' | 'normal' | 'brisk'
interface LoopPreset {
id: LoopId
bpm: number
label: string
desc: string
}
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
const VIDEO_BASE_BPM = 124
const LOOP_PRESETS: readonly LoopPreset[] = [
{ id: 'slow', bpm: 110, label: '慢走', desc: '热身 · 恢复' },
{ id: 'normal', bpm: 130, label: '健走', desc: '日常 · 通勤' },
{ id: 'brisk', bpm: 150, label: '快走', desc: '提速 · 燃脂' },
]
/* ============================================================
* 双引擎设计
* ------------------------------------------------------------
* - mode='preset': BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
* - mode='custom': InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
* - 切模式时停掉对侧引擎,避免双声道叠加
* ============================================================ */
type Mode = 'preset' | 'custom'
const mode = ref<Mode>('preset')
const customExpanded = ref<boolean>(false)
const currentLoop = ref<LoopId | null>('normal')
const currentSound = ref<'crisp' | 'wood'>('crisp')
const walkerCanvasRef = ref<any>(null)
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
const CLICK_WOOD_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
/* 节拍器音效配置 */
const SOUND_PRESETS = {
crisp: {
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539371ebe13672.mp3',
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539376ff628472.mp3',
},
wood: {
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
},
}
const bg = useMetronomeBg()
//
const fg = useMetronome({
initialBpm: 100,
initialBpm: 130,
accentEvery: 2,
clickSrc: CLICK_WOOD_URL,
accentSrc: CLICK_WOOD_URL,
clickSrc: SOUND_PRESETS.crisp.normal,
accentSrc: SOUND_PRESETS.crisp.accent,
poolSize: 4,
})
const { LOOP_PRESETS } = bg
const currentLoop = bg.currentLoop
const customBpm = computed(() => fg.bpm.value)
const customAccent = computed(() => fg.accentEvery.value)
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
const isPlaying = computed(() =>
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
)
const currentBpm = computed(() => {
if (mode.value === 'preset') {
const p = LOOP_PRESETS.find((p) => p.id === currentLoop.value)
return p?.bpm ?? 110
//
onBeat: (index, isAccent) => {
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
walkerCanvasRef.value.triggerBeat()
}
}
return fg.bpm.value
})
const intervalMs = computed(() => 60000 / currentBpm.value)
const isPlaying = computed(() => fg.isPlaying.value)
const currentBpm = computed(() => fg.bpm.value)
const customAccent = computed(() => fg.accentEvery.value)
const intervalMs = computed(() => 60000 / fg.bpm.value)
/* ============================================================
* 档位元数据
* ============================================================ */
const presetIcon = (id: LoopId) =>
id === 'slow' ? '🚶' : id === 'normal' ? '🏃‍♀️' : '🏃'
const presetName = (id: LoopId) =>
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
const presetDesc = (id: LoopId) =>
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
/* ============================================================
* 中央圆按钮:在两个模式下分别工作
* 中央圆按钮
* ============================================================ */
const onCenterTap = () => {
if (mode.value === 'preset') {
if (!currentLoop.value) {
bg.playLoop('normal')
uni.setKeepScreenOn({ keepScreenOn: true })
return
}
if (bg.isPlaying.value) {
bg.pause()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
bg.resume()
uni.setKeepScreenOn({ keepScreenOn: true })
}
if (fg.isPlaying.value) {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
/* custom 模式 */
if (fg.isPlaying.value) {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
/* ============================================================
* 档位卡片:点了 切回 preset 模式 + 停掉 custom
* 档位卡片
* ============================================================ */
const onPresetTap = (id: LoopId) => {
if (mode.value === 'custom') {
fg.stop()
mode.value = 'preset'
customExpanded.value = false
const preset = LOOP_PRESETS.find(p => p.id === id)
if (!preset) return
currentLoop.value = id
fg.setBpm(preset.bpm)
if (!fg.isPlaying.value) {
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
bg.playLoop(id)
uni.setKeepScreenOn({ keepScreenOn: true })
}
/* ============================================================
* 自定义折叠区切换
* ============================================================ */
const onToggleCustom = () => {
if (!customExpanded.value) {
/* 展开:进入 custom 模式 + 停掉 preset */
if (bg.isPlaying.value || currentLoop.value) {
bg.stop()
}
mode.value = 'custom'
/* 把当前显示 BPM 带过去当起始值 */
const seed =
LOOP_PRESETS.find((p) => p.id === (currentLoop.value ?? 'normal'))?.bpm ?? 100
fg.setBpm(seed)
fg.setAccentEvery(2)
/* 立即预热 InnerAudio,避免首拍冷启动延迟 */
customExpanded.value = !customExpanded.value
if (customExpanded.value) {
fg.preload()
customExpanded.value = true
} else {
/* 收起:回到 preset,停掉 fg */
fg.stop()
mode.value = 'preset'
customExpanded.value = false
uni.setKeepScreenOn({ keepScreenOn: false })
// BPM
const matched = LOOP_PRESETS.find(p => p.bpm === fg.bpm.value)
currentLoop.value = matched ? matched.id : null
}
}
const onBpmDelta = (delta: number) => {
fg.setBpm(fg.bpm.value + delta)
currentLoop.value = null //
}
const onAccentSelect = (n: number) => {
fg.setAccentEvery(n)
}
/* ============================================================
* 视频实例与 BPM 同步
* ============================================================ */
let videoCtx: UniApp.VideoContext | null = null
const playbackRate = computed(() => {
const rate = currentBpm.value / VIDEO_BASE_BPM
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
})
onMounted(() => {
videoCtx = uni.createVideoContext('runner-video')
setTimeout(() => videoCtx?.pause(), 50)
})
const onVideoError = (e: any) => {
console.error('[runner-video] error:', e?.detail || e)
const onSoundSelect = (sound: 'crisp' | 'wood') => {
currentSound.value = sound
const preset = SOUND_PRESETS[sound]
fg.updateAudioSrc(preset.normal, preset.accent)
}
const onVideoLoaded = (e: any) => {
console.log('[runner-video] metadata loaded:', e?.detail)
try {
videoCtx?.playbackRate?.(playbackRate.value)
} catch (_) {}
}
watch(isPlaying, (playing) => {
if (playing) {
videoCtx?.play()
} else {
videoCtx?.pause()
}
})
watch(playbackRate, (rate) => {
try {
videoCtx?.playbackRate?.(rate)
} catch (_) {}
})
const rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
onShow(() => {
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
if (mode.value === 'custom') {
fg.preload()
}
fg.preload()
})
/**
* onHide:差异化处理
* - preset 模式: 不停,BgAudio 接管后台播放
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
*/
onHide(() => {
if (mode.value === 'custom') {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
})
onUnmounted(() => {
/* 页面销毁时全部清理 */
if (mode.value === 'preset' && bg.isPlaying.value) bg.stop()
if (mode.value === 'custom') fg.stop()
function cleanupMetronome() {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
})
}
onHide(cleanupMetronome)
onUnload(cleanupMetronome)
onUnmounted(cleanupMetronome)
</script>
<style lang="scss" scoped>
@@ -359,166 +252,70 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
}
/* ============================================================
* 节拍器主舞台
* 统一运动律动视区 (Canvas)
* ============================================================ */
.stage {
position: relative;
width: 480rpx;
height: 480rpx;
margin-top: 12rpx;
.stage-canvas-wrapper {
width: 100vw;
margin-left: -28rpx;
margin-right: -28rpx;
flex-shrink: 0; /* 防止被压缩 */
height: 600rpx; /* 固定高度,不随内容变化 */
display: flex;
align-items: center;
justify-content: center;
&:active .core {
transform: scale(0.97);
}
}
.ring {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 3rpx solid rgba(16, 185, 129, 0.12);
pointer-events: none;
&.playing {
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
}
&.r2.playing {
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
}
}
@keyframes ring-pulse {
0% {
transform: scale(0.94);
opacity: 0.85;
border-color: rgba(16, 185, 129, 0.4);
}
100% {
transform: scale(1.18);
opacity: 0;
border-color: rgba(16, 185, 129, 0);
}
}
.core {
position: relative;
width: 380rpx;
height: 380rpx;
border-radius: 50%;
background: linear-gradient(140deg, #34d399 0%, $brand-deep 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow:
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
0 16rpx 40rpx rgba(16, 185, 129, 0.32);
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
&.playing {
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
}
.bpm-num {
font-size: 168rpx;
font-weight: 800;
color: #fff;
line-height: 1;
letter-spacing: -2rpx;
font-variant-numeric: tabular-nums;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.16);
}
.bpm-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 6rpx;
margin-top: 10rpx;
font-weight: 600;
}
.ctrl {
margin-top: 22rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
@keyframes core-beat {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.04);
}
}
/* 播放/暂停 icon */
.icon-play {
width: 0;
height: 0;
border-left: 52rpx solid #fff;
border-top: 32rpx solid transparent;
border-bottom: 32rpx solid transparent;
margin-left: 12rpx;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
}
.icon-pause {
display: flex;
gap: 16rpx;
height: 60rpx;
.bar {
width: 16rpx;
height: 100%;
background: #fff;
border-radius: 4rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
}
margin-bottom: 24rpx;
position: relative;
}
/* ============================================================
* 跑步小人
* 音色选择器
* ============================================================ */
.walker {
.sound-selector {
width: 100%;
height: 240rpx;
display: flex;
justify-content: center;
gap: 14rpx;
margin-bottom: 20rpx;
}
.sound-option {
flex: 1;
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 20rpx 16rpx;
display: flex;
align-items: center;
}
justify-content: center;
gap: 10rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.walker-box {
position: relative;
width: 240rpx;
height: 240rpx;
background: #e8ecf2;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06);
}
.sound-icon {
font-size: 32rpx;
}
.runner-video {
width: 100%;
height: 100%;
background: #e8ecf2;
}
.sound-label {
font-size: 26rpx;
font-weight: 600;
color: $text-2;
letter-spacing: 1rpx;
}
.pip-mask {
position: absolute;
top: 0;
right: 0;
width: 80rpx;
height: 56rpx;
background-color: #dde2eb;
border-bottom-left-radius: 18rpx;
border-top-right-radius: 24rpx;
&:active {
transform: scale(0.97);
}
&.active {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-color: $brand;
box-shadow:
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
.sound-label {
color: $brand-deep;
}
}
}
/* ============================================================
@@ -535,19 +332,14 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 18rpx 8rpx 16rpx;
padding: 26rpx 8rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
gap: 8rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.preset-icon {
font-size: 40rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.preset-name {
font-size: 28rpx;
font-weight: 700;
@@ -591,9 +383,32 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
}
/* ============================================================
* 自定义节奏面板( customExpanded 时显示)
* 自定义节奏区域(标题栏 + 折叠内容)
* ============================================================ */
.custom-panel {
.custom-section {
width: 100%;
margin-top: 12rpx;
}
.custom-header {
width: 100%;
padding: 16rpx 6rpx;
display: flex;
align-items: center;
justify-content: center;
.custom-title {
font-size: 22rpx;
color: $text-3;
letter-spacing: 0.5rpx;
&:active {
color: $text-2;
}
}
}
.custom-content {
width: 100%;
background: $card-bg;
border: 2rpx solid rgba(245, 158, 11, 0.25);
@@ -603,6 +418,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 8rpx;
}
.custom-warn {
@@ -705,7 +521,7 @@ $shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
}
/* ============================================================
* 底部行(自定义入口 + 锁屏提示,横向排布)
* 底部行(已废弃,保留样式以防引用)
* ============================================================ */
.bottom-row {
width: 100%;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,276 @@
# 握力环训练音效采集清单
> 更新日期: 2026-05-28
> 用途: 为握力环训练页面添加完整音效体系
---
## 📋 总体规划
| 类型 | 数量 | 单文件大小 | 总大小预算 | 优先级 |
|------|------|-----------|-----------|--------|
| 节拍音效 | 1-3 个音色 | 5-10 KB | < 30 KB | P0 |
| 捏碎音效 | 4 个 | 20-50 KB | < 200 KB | P0 |
| 背景音乐 | 1-2 首 | 200-500 KB | < 1 MB | P1 |
| 语音鼓励 | 4-8 句 | 10-30 KB | < 200 KB | P1 |
**总计预算**: < 1.5 MB(都放 CDN,不影响小程序包体积)
---
## 🥁 一、节拍音效 (P0 - 必需)
### 用途
训练时按 BPM 节奏播放,引导用户握紧/松开。
### 推荐音色 (任选 1-3 个)
| 音色 | 推荐度 | 描述 | Pixabay 搜索词 |
|------|--------|------|---------------|
| 木鱼 | ⭐⭐⭐⭐⭐ | 温暖、东方意境,适合养生主题 | `wood block`, `wooden tap`, `muyu` |
| 心跳 | ⭐⭐⭐⭐ | 有运动感,代入感强 | `heart beat`, `heartbeat single` |
| 轻柔敲击 | ⭐⭐⭐⭐ | 不刺耳,适合长时间训练 | `soft tap`, `gentle knock` |
| 竹片声 | ⭐⭐⭐ | 清脆,有节奏感 | `bamboo tap`, `bamboo click` |
| 鼓点 | ⭐⭐⭐ | 有力量感,适合"重度"档位 | `kick drum soft`, `tom drum tap` |
### 技术要求
- **格式**: mp3
- **时长**: 50-150ms (不能太长,会重叠)
- **采样率**: 22050Hz 或 44100Hz
- **声道**: 单声道
- **大小**: < 10 KB
### 命名规范
```
grip-tick-wood.mp3 # 木鱼
grip-tick-heart.mp3 # 心跳
grip-tick-soft.mp3 # 轻柔
grip-tick-bamboo.mp3 # 竹片
```
---
## 💥 二、捏碎音效 (P0 - 必需)
### 用途
达成奖励里程碑时播放,与粒子特效同步。
### 音效清单
| 奖励 | 触发次数 | Pixabay 搜索词 | 时长 | 备注 |
|------|---------|---------------|------|------|
| 🥚 鸡蛋 | 8-12 | `egg crack`, `eggshell break`, `crack sound` | 0.5-1s | 清脆短促 |
| 🥜 核桃 | 15-20 | `nut crack`, `walnut break`, `wood snap` | 0.5-1s | 木质爆裂感 |
| 🥫 易拉罐 | 25-30 | `can crush`, `aluminum crush`, `metal crunch` | 1-2s | 金属挤压 |
| 🎈 气球 | 40-50 | `balloon pop`, `pop burst`, `balloon explosion` | 0.3-0.5s | 短促爆裂 |
### 技术要求
- **格式**: mp3
- **时长**: 0.3-2s
- **大小**: < 50 KB
- **音量**: 适中(避免突然吓到用户)
### 命名规范
```
crush-egg.mp3
crush-walnut.mp3
crush-can.mp3
crush-balloon.mp3
```
### 试听建议
下载前一定要试听,优先选择:
- ✅ 干净,无杂音
- ✅ 中等音量(峰值不爆音)
- ✅ 短促有力(不拖沓)
- ❌ 避免: 太长、太尖锐、有人声
---
## 🎵 三、背景音乐 BGM (P1 - 推荐)
### 用途
训练时持续播放,营造轻松氛围,缓解握力训练的枯燥感。
### 风格推荐
| 风格 | 描述 | Pixabay 搜索词 | 适合人群 |
|------|------|---------------|---------|
| **Lo-Fi** | 慵懒电子,流行选择 | `lofi calm`, `lofi study`, `lofi chill` | 年轻人 |
| **冥想轻音乐** | 钢琴 + 自然音,放松 | `meditation`, `calm piano`, `relaxing` | 中老年 |
| **轻爵士** | 优雅放松,有质感 | `light jazz`, `cafe jazz`, `bossa nova` | 通用 |
| **自然环境音** | 鸟叫/流水/雨声 | `nature ambient`, `forest sounds`, `rain bgm` | 喜欢自然的用户 |
| **东方禅意** | 古筝/笛子,养生 | `chinese zen`, `oriental calm`, `guqin` | 养生主题契合 |
### 推荐场景搭配
- **轻度档位 (60 BPM)**: 冥想轻音乐 (节奏舒缓)
- **中度档位 (80 BPM)**: 轻爵士 / Lo-Fi (适中)
- **重度档位 (100 BPM)**: 轻快电子 / 健身流行
### 技术要求
- **格式**: mp3
- **时长**: 30s - 2min (循环播放)
- **比特率**: 128 kbps (单声道 64 kbps 也可)
- **大小**: < 500 KB
- **循环点**: 选择起止点接近的片段,避免明显接缝
### 命名规范
```
bgm-grip-light.mp3 # 轻度档位 BGM
bgm-grip-medium.mp3 # 中度档位 BGM (可选)
bgm-grip-heavy.mp3 # 重度档位 BGM (可选)
```
或简化:
```
bgm-grip-default.mp3 # 默认 BGM (所有档位通用)
```
---
## 🎙️ 四、语音鼓励 (P1 - 推荐)
### 用途
达成奖励时播放鼓励语音,增强成就感。
### 两种方案
#### 方案 A: 小米 MiMo TTS 自动生成 (推荐)
项目已有 `useVoiceCoach.ts` + `generate-voice.mjs` 脚本支持。
**操作步骤**:
1. 申请小米 MiMo API Key: https://platform.xiaomimimo.com/
2. 配置环境变量: `export MIMO_API_KEY=sk-xxx`
3. 在 `generate-voice.mjs` 添加握力环话术
4. 运行脚本自动生成
**话术清单**:
```javascript
const gripPrompts = [
{ id: 'grip-egg', text: '握力不错!' },
{ id: 'grip-walnut', text: '力量惊人!' },
{ id: 'grip-can', text: '太强了!' },
{ id: 'grip-balloon', text: '完美!继续保持!' },
{ id: 'grip-start', text: '开始训练!' },
{ id: 'grip-pause', text: '休息一下!' },
{ id: 'grip-encourage-1', text: '加油,再来!' },
{ id: 'grip-encourage-2', text: '坚持住!' },
]
```
**音色推荐**: `茉莉` (清爽利落,有"运动博主"感)
#### 方案 B: 手工录制
如果不想用 TTS,可以自己录制(质感更好,但费时):
- 工具: 手机录音 / Audacity
- 环境: 安静的房间,距离麦克风 15-30cm
- 后期: 去噪 + 标准化音量
---
## 🛠️ 五、采集工具推荐
### 在线裁剪
- [audiotrimmer.com](https://audiotrimmer.com) - 简单的 mp3 裁剪
- [mp3cut.net](https://mp3cut.net) - 功能更全
### 桌面工具(免费)
- **Audacity** (Mac/Windows/Linux) - 专业级,可以剪辑、降噪、标准化
- **Logic Pro** (Mac, 付费) - 更专业的音频处理
### 音量标准化
统一音量(避免有的太响有的太轻):
```bash
# 使用 ffmpeg (Mac: brew install ffmpeg)
ffmpeg -i input.mp3 -af "loudnorm=I=-16:LRA=11:TP=-1.5" output.mp3
```
或在 Audacity 里: Effect → Normalize (峰值 -3dB)
---
## ☁️ 六、上传到 CDN
### 项目使用的 COS 信息
- **域名**: `gz-1349751149.cos.ap-guangzhou.myqcloud.com`
- **目录建议**: `uploads/training/grip-ring/`
### 上传途径
1. **腾讯云控制台**: https://console.cloud.tencent.com/cos
2. **管理后台** (如果项目有上传入口)
3. **找团队 COS 管理员上传**
### 上传后获得的 URL 格式
```
https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/training/grip-ring/crush-egg.mp3
```
---
## 📝 七、最终采集清单 (一次性收齐)
### 最小可行版本 (MVP) - 4 个文件
```
☐ grip-tick-wood.mp3 # 节拍音 - 木鱼
☐ crush-egg.mp3 # 鸡蛋
☐ crush-walnut.mp3 # 核桃
☐ crush-can.mp3 # 易拉罐
☐ crush-balloon.mp3 # 气球
```
### 完整版本 - 10 个文件
```
节拍音 (3 选 1 或全部):
☐ grip-tick-wood.mp3
☐ grip-tick-heart.mp3
☐ grip-tick-soft.mp3
捏碎音效 (必需 4 个):
☐ crush-egg.mp3
☐ crush-walnut.mp3
☐ crush-can.mp3
☐ crush-balloon.mp3
BGM (1-3 首):
☐ bgm-grip-default.mp3
☐ bgm-grip-meditation.mp3 (可选)
☐ bgm-grip-lofi.mp3 (可选)
语音鼓励 (TTS 自动生成,8 个):
☐ grip-egg.mp3
☐ grip-walnut.mp3
☐ grip-can.mp3
☐ grip-balloon.mp3
☐ grip-start.mp3
☐ grip-pause.mp3
☐ grip-encourage-1.mp3
☐ grip-encourage-2.mp3
```
---
## ✅ 八、采集完成后
把所有 URL 整理给我,我会:
1. 更新 `grip-ring.vue` 中的 REWARDS 配置 (替换捏碎音效 URL)
2. 创建/更新 `useMetronome.ts` 的音色配置 (支持切换节拍音色)
3. 在 `grip-ring.vue` 集成 `useTrainingBgm` (添加 BGM)
4. 在 `grip-ring.vue` 集成 `useVoiceCoach` (添加语音鼓励)
5. 在 UI 上添加音色/BGM 切换选项 (可选)
预计代码改动: 50-100 行,1-2 小时工作量。
---
## 📚 参考链接
- [Pixabay 音效](https://pixabay.com/sound-effects/) - 免费可商用
- [Pixabay 音乐](https://pixabay.com/music/) - 免费可商用
- [Freesound](https://freesound.org) - 大量音效,部分需署名
- [Mixkit](https://mixkit.co/free-sound-effects/) - 免费音效库
- [Bensound](https://bensound.com) - 免费音乐(需署名)
- [Free Music Archive](https://freemusicarchive.org) - 免费音乐
**版权提示**: 务必确认音效/音乐是"免费商用"(Public Domain 或 CC0),避免侵权风险。
+115
View File
@@ -0,0 +1,115 @@
/**
*
* MET (Metabolic Equivalent of Task)
*/
export interface FoodItem {
name: string
emoji: string
calories: number
unit: string
}
export interface FoodComparison {
food: FoodItem
ratio: number
message: string
}
export interface CalorieCalculationConfig {
totalReps: number
bpm: number
userWeight?: number // 默认 60kg
}
// 食物卡路里数据库
export const FOOD_CALORIES: Record<string, FoodItem> = {
apple: { name: '苹果', emoji: '🍎', calories: 52, unit: '个(100g' },
egg: { name: '鸡蛋', emoji: '🥚', calories: 70, unit: '个' },
chocolate: { name: '巧克力', emoji: '🍫', calories: 54, unit: '块(10g' },
banana: { name: '香蕉', emoji: '🍌', calories: 89, unit: '根' },
rice: { name: '米饭', emoji: '🍚', calories: 116, unit: '碗(100g' },
}
/**
*
* = MET × (kg) × ()
*
* @param config
* @returns kcal
*/
export function calculateCalories(config: CalorieCalculationConfig): number {
const MET = 3.5 // 握力环训练的标准 MET 值
const weight = config.userWeight || 60 // 默认 60kg
// 边界情况:无效输入
if (config.totalReps <= 0 || config.bpm <= 0) {
return 0
}
const secondsPerRep = 60 / config.bpm
const activeTimeHours = (config.totalReps * secondsPerRep) / 3600
const calories = MET * weight * activeTimeHours
return Math.round(calories * 10) / 10 // 保留一位小数
}
/**
*
*
* @param burnedCalories
* @returns
*/
export function getFoodComparison(burnedCalories: number): FoodComparison {
const foods = Object.values(FOOD_CALORIES)
// 边界情况:食物数据库为空
if (foods.length === 0) {
throw new Error('食物数据库为空')
}
// 边界情况:卡路里为 0 或负数
if (burnedCalories <= 0) {
return {
food: foods[0],
ratio: 0,
message: `暂无消耗`,
}
}
// 找到卡路里最接近的食物
const closest = foods.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.calories - burnedCalories)
const currDiff = Math.abs(curr.calories - burnedCalories)
return currDiff < prevDiff ? curr : prev
})
const ratio = burnedCalories / closest.calories
const ratioRounded = Math.round(ratio * 100) / 100 // 保留两位小数
// 格式化显示:ratio < 1 时显示分数形式更直观
let displayText: string
if (ratioRounded < 1) {
displayText = `相当于 ${ratioRounded.toFixed(2)}${closest.name} ${closest.emoji}`
} else {
displayText = `相当于 ${ratioRounded.toFixed(2)} ${closest.unit}${closest.name} ${closest.emoji}`
}
return {
food: closest,
ratio: ratioRounded,
message: displayText,
}
}
/**
* :
*
* @param seconds
* @returns "3:45"
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
// 编译产物默认输出到 ./dist/<mode>/<platform>
export default defineConfig({
plugins: [uni()],
css: {
preprocessorOptions: {
scss: {
// 屏蔽 TUICallKit SDK 内部 @import / legacy-js-api 弃用警告
// 待 TUICallKit 升级 @use 语法后可移除
silenceDeprecations: ['legacy-js-api', 'import'],
},
},
},
// dev 热重载卡顿修复:
// UNI_INPUT_DIR=$PWD 让工程根=源码目录, chokidar 会监听整个仓库,
// 包含 node_modules / dist(编译产物) / unpackage 等巨大目录, 导致每次热重载卡住,
// 写 dist 又触发监听抖动。这里收窄监听范围。
// 注意: 自定义 ignored 会覆盖 vite 默认值, 故 node_modules/.git 必须一并列出。
server: {
watch: {
ignored: [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/unpackage/**',
],
},
},
})
+1
View File
@@ -100,6 +100,7 @@ export function doctorDailyStatsOverview(params: {
start_date?: string
end_date?: string
channel_code?: string
dept_ids?: number[] | string
}) {
return request.get({ url: '/stats.doctorDailyStats/overview', params })
}
+6 -1
View File
@@ -482,11 +482,16 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
}
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态,不含退款 */
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
}
/** 业务订单退款(须填写原因;关联收款单标记已退款、已付总额清零) */
export function prescriptionOrderRefund(params: { id: number; reason: string }) {
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
}
/** 甘草药管家:中药处方下单(服务端 CTM_PREVIEW → CTM_SUBMIT_RECIPEL */
export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
+180 -51
View File
@@ -518,6 +518,13 @@
</div>
<div v-else class="rx-wrap">
<el-tabs
v-model="prescriptionTabType"
class="rx-slip-tabs"
>
<el-tab-pane label="药房联" name="internal" />
<el-tab-pane label="处方联" name="user" />
</el-tabs>
<div class="rx-toolbar">
<el-tag v-if="savedPrescription.void_status === 1" type="danger" size="small">已作废</el-tag>
<el-tag v-else-if="approvedPreviewOnly" type="success" size="small">已通过</el-tag>
@@ -543,6 +550,7 @@
<div class="prescription-preview-content">
<div v-if="savedPrescription" ref="prescriptionPrintRef" class="rx-paper">
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
@@ -597,49 +605,76 @@
</div>
<div class="rx-rp">
<div class="rx-watermark">药房联</div>
<div class="rx-watermark">{{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }}</div>
<div class="rx-rp-head">
<div class="rx-rp-label">Rp.</div>
<div class="rx-rp-cols-head">
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
</div>
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
</div>
</div>
</div>
<div class="rx-herbs">
<template v-if="savedMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in savedMainHerbs"
:key="'main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
<template v-if="prescriptionTabType === 'internal'">
<template v-if="savedMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in savedMainHerbs"
:key="'main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
<template v-if="savedAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in savedAuxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
</template>
<template v-if="savedAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in savedAuxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
<template v-else>
<template v-if="savedMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in savedMainHerbs"
:key="'user-main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
<template v-if="savedAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in savedAuxHerbs"
:key="'user-aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
</template>
</div>
</div>
<div class="rx-text">
<p>主方服法{{ rxUsageText }}</p>
<p v-if="savedAuxUsageText">辅方服法{{ savedAuxUsageText }}</p>
<p v-if="prescriptionTabType === 'internal' || savedAuxUsageText">主方服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="savedAuxUsageText">{{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}{{ savedAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
@@ -671,18 +706,22 @@
</div>
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">天数:</span>
<span class="rx-bot-meta-val">{{ savedPrescription.dose_count || '—' }}</span>
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<span class="rx-bot-meta-key">剂量:</span>
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}</span>
</div>
</div>
<div v-if="prescriptionTabType === 'user'" class="rx-bot-row rx-hospital-row">
<div class="rx-hospital-line">成都双流甄养堂互联网医院有限公司 联系方式4001667339</div>
<div class="rx-hospital-line">地址四川省成都市双流区黄甲街道黄龙大道二段280号</div>
</div>
</div>
</div>
<!-- 详细病历 - 单独A3纸张 -->
<div v-if="savedPrescription.case_record" ref="caseRecordPrintRef" class="case-record-a3">
<div v-if="savedPrescription.case_record && !hideCaseRecordPreview" ref="caseRecordPrintRef" class="case-record-a3">
<div class="case-record-a3-inner">
<h2 class="case-record-a3-title">{{ templateConfig.stationName }} 详细病历</h2>
<div class="case-record-a3-body">
@@ -1148,6 +1187,10 @@ const signatureActivePointerId = ref<number | null>(null)
const SIGNATURE_CSS_W = 560
const SIGNATURE_CSS_H = 168
const savedPrescription = ref<any>(null)
/** 预览联次:药房联 / 处方联(与消费者处方查看一致) */
const prescriptionTabType = ref<'internal' | 'user'>('internal')
/** 诊单列表「查看」仅展示处方联,不展示详细病历 A3 */
const hideCaseRecordPreview = ref(false)
/** 已通过且未作废:与消费者处方「查看」一致,仅预览,不可作废/再开新方 */
const approvedPreviewOnly = computed(() => {
@@ -1279,38 +1322,76 @@ const rxPharmacyRemarkText = computed(() => {
return v.pharmacy_remark || v.pharmacy_note || ''
})
const rxOutPelletText = computed(() => {
/** 笺面用药天数:与 consumer/prescription/order_list 一致 */
const rxSlipMedicationDaysText = computed(() => {
const v = savedPrescription.value as any
if (!v) return ''
const out = computeRxOutPellet(v)
return out ? `${out}` : ''
if (!v) return ''
const md = v.medication_days ?? v.usage_days
if (md != null && String(md).trim() !== '') return `${md}`
const dc = v.dose_count
if (dc != null && String(dc).trim() !== '' && Number(dc) > 0) return `${dc}`
return '—'
})
function computeRxOutPellet(v: any): string {
/** 主方出丸:用药疗程 medication_days → usage_days → dose_count;出丸 = 每袋用量×袋数×每天次数×服用天数 */
const slipPillGrams = computed(() => {
const d = savedPrescription.value as any
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
const days = Number(d.medication_days ?? d.usage_days ?? d.dose_count)
const times = Number(d.times_per_day)
const doseG = Number(d.dosage_amount)
const bags = Number(d.dosage_bag_count) > 0 ? Number(d.dosage_bag_count) : 1
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
if (days <= 0 || times <= 0 || doseG <= 0) return null
return days * times * doseG * bags
})
/** 辅方出丸:公式同主方,用 aux_usage 字段 */
const slipAuxPillGrams = computed(() => {
const d = savedPrescription.value as any
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
if (!savedAuxHerbs.value.length) return null
const aux = normalizeAuxUsageForm(d.aux_usage, d.prescription_type || '浓缩水丸')
const days = Number(d.medication_days ?? aux.usage_days)
const times = Number(aux.times_per_day)
const doseG = Number(aux.dosage_amount)
const bags = Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
if (days <= 0 || times <= 0 || doseG <= 0) return null
return days * times * doseG * bags
})
/** 出丸:优先 slipPillGrams + slipAuxPillGrams,否则 out_pellet / 药材总量×剂数 */
const rxOutPelletGrams = computed(() => {
const main = slipPillGrams.value
const aux = slipAuxPillGrams.value
const mainNum = typeof main === 'number' && isFinite(main) && main > 0 ? main : 0
const auxNum = typeof aux === 'number' && isFinite(aux) && aux > 0 ? aux : 0
const sum = mainNum + auxNum
if (sum > 0) return formatRxNum(sum)
const v = savedPrescription.value as any
if (!v) return ''
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const days = Number(v.usage_days ?? v.dose_count)
const times = Number(v.times_per_day)
const doseG = Number(v.dosage_amount)
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
if (
Number.isFinite(days) &&
Number.isFinite(times) &&
Number.isFinite(doseG) &&
days > 0 &&
times > 0 &&
doseG > 0
) {
return formatRxNum(days * times * doseG * bags)
}
}
const explicit = v.out_pellet || v.total_weight
if (explicit && Number(explicit) > 0) return formatRxNum(Number(explicit))
const list = savedSlipHerbsList.value
if (!list.length) return ''
const perDose = list.reduce((acc: number, h: any) => acc + (Number(h?.dosage) || 0), 0)
return formatRxNum(perDose * rxDoseCount.value)
}
})
const rxOutPelletText = computed(() => {
const out = rxOutPelletGrams.value
if (!out) return ''
const main = slipPillGrams.value
const aux = slipAuxPillGrams.value
if (
typeof main === 'number' && isFinite(main) && main > 0 &&
typeof aux === 'number' && isFinite(aux) && aux > 0
) {
return `${out}克(主方 ${formatRxNum(main)}克 + 辅方 ${formatRxNum(aux)}克)`
}
return `${out}`
})
function formatRxNum(n: number): string {
if (!isFinite(n)) return '0'
@@ -1542,6 +1623,8 @@ async function handlePasteRecipeImport() {
}
}
const SLIP_TITLE = '成都双流甄养堂互联网医院 处方笺'
const templateConfig = reactive({
stationName: '成都双流甄养堂互联网医院',
showDisclaimer: true,
@@ -1790,7 +1873,14 @@ const open = async (data: any, options?: { templateId?: number; stationName?: st
}
}
const openById = async (prescriptionId: number) => {
type OpenByIdOptions = {
/** 默认 internal;诊单列表查看传 user 展示处方联 */
slipType?: 'internal' | 'user'
/** 为 true 时不展示详细病历 A3 */
hideCaseRecord?: boolean
}
const openById = async (prescriptionId: number, options?: OpenByIdOptions) => {
//
if (isOpening.value) {
console.warn('处方正在打开中,请勿重复点击')
@@ -1798,6 +1888,8 @@ const openById = async (prescriptionId: number) => {
}
isOpening.value = true
prescriptionTabType.value = options?.slipType ?? 'internal'
hideCaseRecordPreview.value = options?.hideCaseRecord ?? false
try {
const res = await prescriptionDetail({ id: prescriptionId })
@@ -1819,6 +1911,8 @@ const openById = async (prescriptionId: number) => {
const handleClose = () => {
visible.value = false
savedPrescription.value = null
prescriptionTabType.value = 'internal'
hideCaseRecordPreview.value = false
}
/**
@@ -2549,6 +2643,15 @@ defineExpose({
margin: -20px;
}
.rx-slip-tabs {
margin-bottom: 0;
z-index: 1;
position: relative;
background: #fff;
padding: 0 4px;
border-radius: 6px 6px 0 0;
}
.rx-toolbar {
display: flex;
align-items: center;
@@ -2593,6 +2696,15 @@ defineExpose({
overflow: visible;
}
.rx-title {
text-align: center;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
margin: 10px 0 35px;
letter-spacing: 0.08em;
}
.rx-notice {
display: flex;
align-items: center;
@@ -2813,6 +2925,23 @@ defineExpose({
min-height: 70px;
}
.rx-hospital-row {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 2px;
padding: 10px 12px;
min-height: 52px;
text-align: left;
}
.rx-hospital-line {
font-size: 12px;
color: #374151;
line-height: 1.6;
}
.rx-bot-cell {
border-right: 1px solid #c8c8c8;
padding: 6px 10px;
@@ -1707,6 +1707,8 @@ type AuxUsageForm = {
bags_per_dose: number
times_per_day: number
usage_days: number
/** 从处方库导入辅方时写入的模板名称,供药房联展示 */
prescription_name?: string
}
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
@@ -1753,7 +1755,8 @@ function normalizeAuxUsageForm(raw: unknown, prescriptionType: string): AuxUsage
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days,
prescription_name: String(o.prescription_name ?? '').trim()
}
}
@@ -3448,6 +3451,10 @@ const handleImportLibrary = (row: any) => {
} else {
editForm.herbs.push(...imported)
}
if (formulaType === '辅方') {
editForm.aux_usage.prescription_name = String(row.prescription_name ?? '').trim()
}
const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : ''
if (findDuplicateHerbNamesLocal().length) {
feedback.msgWarning(
@@ -293,7 +293,7 @@
:fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams"
:page-size="pager.size"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
/>
</el-form-item>
</el-form>
@@ -530,6 +530,13 @@
link
@click="confirmComplete(row)"
>完成订单</el-button>
<el-button
v-if="canRefundRow(row)"
v-perms="['tcm.prescriptionOrder/refund']"
type="danger"
link
@click="openRefundOrder(row)"
>退款</el-button>
<el-button
v-if="canWithdrawRow(row)"
v-perms="['tcm.prescriptionOrder/withdraw']"
@@ -672,6 +679,16 @@
>
完成订单
</el-button>
<el-button
v-if="canRefundRow(detailData)"
v-perms="['tcm.prescriptionOrder/refund']"
type="danger"
size="small"
plain
@click="openRefundOrder(detailData)"
>
退款
</el-button>
</div>
</div>
</template>
@@ -2103,6 +2120,41 @@
</el-button>
</template>
</el-dialog>
<!-- 订单退款须填写原因 -->
<el-dialog
v-model="refundOrderDialogVisible"
title="订单退款"
width="480px"
:close-on-click-modal="false"
destroy-on-close
@closed="onRefundOrderDialogClosed"
>
<el-alert
type="warning"
:closable="false"
show-icon
class="mb-4 !items-start"
title="确认退款后,关联收款记录将全部标记为「已退款」,已付总额清零,且不可撤销。"
/>
<el-form ref="refundOrderFormRef" :model="refundOrderForm" :rules="refundOrderRules" label-width="88px">
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="refundOrderForm.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请填写退款原因(必填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refundOrderDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="refundOrderSubmitting" @click="submitRefundOrder">
确认退款
</el-button>
</template>
</el-dialog>
<!-- 补齐支付单 Dialog -->
<el-dialog
v-model="addPayOrderVisible"
@@ -2363,7 +2415,7 @@
<!-- A4 处方纸药房联 -->
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : '处方单' }}</div>
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
@@ -2449,7 +2501,9 @@
</div>
</template>
<template v-if="slipAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div class="rx-herb-section-label rx-herb-section-label--aux">
辅方<span v-if="slipAuxLibraryName" class="rx-herb-library-name">{{ slipAuxLibraryName }}</span>
</div>
<div
v-for="(h, i) in slipAuxHerbs"
:key="'aux-' + i"
@@ -2461,10 +2515,28 @@
</template>
</template>
<template v-else>
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
<template v-if="slipMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in slipMainHerbs"
:key="'user-main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
<template v-if="slipAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in slipAuxHerbs"
:key="'user-aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
</template>
</div>
</div>
@@ -2707,6 +2779,7 @@ import {
prescriptionOrderAddPayOrder,
prescriptionOrderRequestCompletion,
prescriptionOrderComplete,
prescriptionOrderRefund,
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
@@ -2714,6 +2787,7 @@ import {
prescriptionOrderSubmitGancaoRecipel,
prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail,
prescriptionLibraryLists,
getDoctors,
getAssistants
} from '@/api/tcm'
@@ -3665,6 +3739,10 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
return (fs === 5 || fs === 6) && Number(row.payment_slip_audit_status) === 1
}
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
return canCompleteRow(row)
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
// (5) (6)
return Number(row.fulfillment_status) === 5
@@ -4066,7 +4144,8 @@ function logActionText(act: string) {
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单'
complete: '完成订单',
refund: '退款'
}
return m[act] || act
}
@@ -5172,7 +5251,6 @@ const completeOrderStatusOptions = [
{ value: 7, label: '进行中' },
{ value: 8, label: '暂不制药' },
{ value: 9, label: '拒收' },
{ value: 10, label: '退款' },
{ value: 11, label: '保留药方' },
{ value: 12, label: '制药缓发' }
] as const
@@ -5248,6 +5326,54 @@ async function submitCompleteOrder() {
}
}
// 退/ +
const refundOrderDialogVisible = ref(false)
const refundOrderId = ref(0)
const refundOrderSubmitting = ref(false)
const refundOrderFormRef = ref<FormInstance>()
const refundOrderForm = reactive({ reason: '' })
const refundOrderRules: FormRules = {
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}
function onRefundOrderDialogClosed() {
refundOrderId.value = 0
refundOrderForm.reason = ''
refundOrderFormRef.value?.clearValidate()
}
function openRefundOrder(row: { id: number }) {
refundOrderId.value = row.id
refundOrderForm.reason = ''
refundOrderDialogVisible.value = true
}
async function submitRefundOrder() {
const id = refundOrderId.value
if (!id) return
const valid = await refundOrderFormRef.value?.validate().catch(() => false)
if (!valid) return
refundOrderSubmitting.value = true
try {
await prescriptionOrderRefund({ id, reason: refundOrderForm.reason.trim() })
feedback.msgSuccess('退款成功')
refundOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
refundOrderSubmitting.value = false
}
}
//
const addPayOrderVisible = ref(false)
const addPayOrderSaving = ref(false)
@@ -5474,12 +5600,15 @@ async function confirmRevokePayAudit(row: { id: number }) {
//
/** 处方笺抬头/页脚(与诊间打印一致) */
const SLIP_HOSPITAL_NAME = '成都双流甄养堂互联网医院'
const SLIP_TITLE = `${SLIP_HOSPITAL_NAME} 处方笺`
const SLIP_COMPANY_LINE = '成都双流甄养堂互联网医院有限公司 联系方式:4001667339'
const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄龙大道二段280号'
const prescriptionViewVisible = ref(false)
const prescriptionViewLoading = ref(false)
const prescriptionViewData = ref<any>(null)
/** 药房联辅方标题:处方库中该辅方模板的 prescription_name(导入或药材匹配解析) */
const slipAuxLibraryName = ref('')
const prescriptionTabType = ref('internal')
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
const prescriptionSlipExporting = ref(false)
@@ -5883,6 +6012,60 @@ function openPrescriptionViewFromEdit() {
openPrescriptionView(editForm)
}
function herbSetMatchKey(herbs: Array<{ name?: unknown; dosage?: unknown }>): string {
return herbs
.map((h) => `${String(h.name ?? '').trim()}:${Number(h.dosage) || 0}`)
.sort()
.join('|')
}
function herbsMatchPrescriptionLibrary(
prescriptionHerbs: Array<{ name?: unknown; dosage?: unknown }>,
libraryHerbs: Array<{ name?: unknown; dosage?: unknown }>
): boolean {
if (!prescriptionHerbs.length || !libraryHerbs.length) return false
if (prescriptionHerbs.length !== libraryHerbs.length) return false
return herbSetMatchKey(prescriptionHerbs) === herbSetMatchKey(libraryHerbs)
}
function readAuxLibraryNameFromUsage(raw: unknown): string {
if (!raw || typeof raw !== 'object') return ''
const o = raw as Record<string, unknown>
return String(o.prescription_name ?? o.library_name ?? '').trim()
}
/** 解析药房联辅方对应的处方库名称:优先 aux_usage 内持久化字段,否则按开方医师处方库辅方模板药材精确匹配 */
async function resolveSlipAuxLibraryName(data: Record<string, unknown>): Promise<string> {
const fromUsage = readAuxLibraryNameFromUsage(data.aux_usage)
if (fromUsage) return fromUsage
const herbs = Array.isArray(data.herbs) ? data.herbs : []
const auxHerbs = herbs.filter((h) => normalizeSlipFormulaType((h as any)?.formula_type) === '辅方')
if (!auxHerbs.length) return ''
const doctorId = Number(data.creator_id) > 0 ? Number(data.creator_id) : 0
if (!doctorId) return ''
try {
const res: any = await prescriptionLibraryLists({
page_no: 1,
page_size: 200,
formula_type: '辅方',
prescribing_creator_id: doctorId
})
const lists = Array.isArray(res?.lists) ? res.lists : []
for (const row of lists) {
const libHerbs = Array.isArray(row?.herbs) ? row.herbs : []
if (herbsMatchPrescriptionLibrary(auxHerbs, libHerbs)) {
return String(row?.prescription_name ?? '').trim()
}
}
} catch {
/* 静默:无匹配时仅展示「辅方」 */
}
return ''
}
async function openPrescriptionView(row: any) {
if (!row.prescription_id) {
feedback.msgWarning('该订单未关联处方')
@@ -5892,6 +6075,7 @@ async function openPrescriptionView(row: any) {
prescriptionViewVisible.value = true
prescriptionViewLoading.value = true
prescriptionViewData.value = null
slipAuxLibraryName.value = ''
prescriptionTabType.value = 'internal'
try {
@@ -5926,12 +6110,15 @@ async function openPrescriptionView(row: any) {
merged.appointment_id = Number(linkedAp.id)
}
prescriptionViewData.value = merged
slipAuxLibraryName.value = await resolveSlipAuxLibraryName(merged)
} else {
prescriptionViewData.value = null
slipAuxLibraryName.value = ''
}
} catch (e: any) {
feedback.msgError(e?.message || '加载处方详情失败')
prescriptionViewVisible.value = false
slipAuxLibraryName.value = ''
} finally {
prescriptionViewLoading.value = false
}
@@ -6585,6 +6772,11 @@ async function downloadPrescriptionSlipPdf() {
margin-top: 4px;
}
.rx-herb-library-name {
font-weight: 500;
margin-left: 2px;
}
.rx-herb-cell {
display: grid;
grid-template-columns: 1fr 64px;
@@ -498,6 +498,9 @@
<el-dropdown-menu>
<el-dropdown-item v-if="canAddPayOrderRow(row)" command="addPayOrder">补齐支付单</el-dropdown-item>
<el-dropdown-item v-if="canCompleteRow(row)" command="complete">完成订单</el-dropdown-item>
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
<span class="text-red-500">退款</span>
</el-dropdown-item>
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item>
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
<span class="text-red-500">撤回订单</span>
@@ -612,6 +615,16 @@
>
完成订单
</el-button>
<el-button
v-if="canRefundRow(detailData)"
v-perms="['tcm.prescriptionOrder/refund']"
type="danger"
size="small"
plain
@click="openRefundOrder(detailData)"
>
退款
</el-button>
</div>
</div>
</template>
@@ -1854,6 +1867,42 @@
</el-button>
</template>
</el-dialog>
<!-- 订单退款须填写原因 -->
<el-dialog
v-model="refundOrderDialogVisible"
title="订单退款"
width="92%"
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="onRefundOrderDialogClosed"
>
<el-alert
type="warning"
:closable="false"
show-icon
class="mb-4 !items-start"
title="确认退款后,关联收款记录将全部标记为「已退款」,已付总额清零,且不可撤销。"
/>
<el-form ref="refundOrderFormRef" :model="refundOrderForm" :rules="refundOrderRules" label-width="88px">
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="refundOrderForm.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请填写退款原因(必填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refundOrderDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="refundOrderSubmitting" @click="submitRefundOrder">
确认退款
</el-button>
</template>
</el-dialog>
<!-- 补齐支付单 Dialog -->
<el-dialog
v-model="addPayOrderVisible"
@@ -2091,7 +2140,7 @@
<!-- A4 处方纸药房联 -->
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
<div class="rx-title">处方单</div>
<div class="rx-title">{{ SLIP_TITLE }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
@@ -2416,6 +2465,7 @@ import {
prescriptionOrderUpdateAmount,
prescriptionOrderAddPayOrder,
prescriptionOrderComplete,
prescriptionOrderRefund,
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
@@ -3237,6 +3287,10 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
return (fs === 5 || fs === 6) && Number(row.payment_slip_audit_status) === 1
}
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
return canCompleteRow(row)
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 5
}
@@ -3286,13 +3340,14 @@ const h5ActiveFilterCount = computed(() => {
// H5:
function hasMoreCardActions(row: Record<string, any>) {
return canAddPayOrderRow(row) || canCompleteRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
}
// H5:
function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
else if (cmd === 'complete') confirmComplete(row as any)
else if (cmd === 'refund') openRefundOrder(row as any)
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any)
else if (cmd === 'withdraw') confirmWithdraw(row as any)
}
@@ -3607,7 +3662,8 @@ function logActionText(act: string) {
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单'
complete: '完成订单',
refund: '退款'
}
return m[act] || act
}
@@ -4553,7 +4609,6 @@ const completeOrderStatusOptions = [
{ value: 7, label: '进行中' },
{ value: 8, label: '暂不制药' },
{ value: 9, label: '拒收' },
{ value: 10, label: '退款' },
{ value: 11, label: '保留药方' },
{ value: 12, label: '制药缓发' }
] as const
@@ -4586,6 +4641,53 @@ async function submitCompleteOrder() {
}
}
const refundOrderDialogVisible = ref(false)
const refundOrderId = ref(0)
const refundOrderSubmitting = ref(false)
const refundOrderFormRef = ref<FormInstance>()
const refundOrderForm = reactive({ reason: '' })
const refundOrderRules: FormRules = {
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}
function onRefundOrderDialogClosed() {
refundOrderId.value = 0
refundOrderForm.reason = ''
refundOrderFormRef.value?.clearValidate()
}
function openRefundOrder(row: { id: number }) {
refundOrderId.value = row.id
refundOrderForm.reason = ''
refundOrderDialogVisible.value = true
}
async function submitRefundOrder() {
const id = refundOrderId.value
if (!id) return
const valid = await refundOrderFormRef.value?.validate().catch(() => false)
if (!valid) return
refundOrderSubmitting.value = true
try {
await prescriptionOrderRefund({ id, reason: refundOrderForm.reason.trim() })
feedback.msgSuccess('退款成功')
refundOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
refundOrderSubmitting.value = false
}
}
//
const addPayOrderVisible = ref(false)
const addPayOrderSaving = ref(false)
@@ -4784,6 +4886,7 @@ async function confirmRevokePayAudit(row: { id: number }) {
//
/** 处方笺抬头/页脚(与诊间打印一致) */
const SLIP_HOSPITAL_NAME = '成都双流甄养堂互联网医院'
const SLIP_TITLE = `${SLIP_HOSPITAL_NAME} 处方笺`
const SLIP_COMPANY_LINE = '成都双流甄养堂互联网医院有限公司 联系方式:4001667339'
const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄龙大道二段280号'
+41 -4
View File
@@ -405,6 +405,18 @@
</div>
<el-table v-loading="linesLoading" :data="linesRows" border stripe size="small" max-height="440">
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
<el-table-column label="患者" min-width="112" show-overflow-tooltip>
<template #default="{ row }">
<div class="cs-patient-cell">
<span class="cs-patient-cell__name">{{ row.patient_name || '—' }}</span>
<span v-if="row.patient_phone || row.patient_gender_text" class="cs-patient-cell__meta cs-muted">
<template v-if="row.patient_gender_text">{{ row.patient_gender_text }}</template>
<template v-if="row.patient_gender_text && row.patient_phone"> · </template>
<template v-if="row.patient_phone">{{ row.patient_phone }}</template>
</span>
</div>
</template>
</el-table-column>
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
</el-table-column>
@@ -492,6 +504,18 @@
</div>
<el-table v-loading="leafOrdersLoading" :data="leafOrdersRows" border stripe size="small" max-height="440">
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
<el-table-column label="患者" min-width="112" show-overflow-tooltip>
<template #default="{ row }">
<div class="cs-patient-cell">
<span class="cs-patient-cell__name">{{ row.patient_name || '—' }}</span>
<span v-if="row.patient_phone || row.patient_gender_text" class="cs-patient-cell__meta cs-muted">
<template v-if="row.patient_gender_text">{{ row.patient_gender_text }}</template>
<template v-if="row.patient_gender_text && row.patient_phone"> · </template>
<template v-if="row.patient_phone">{{ row.patient_phone }}</template>
</span>
</div>
</template>
</el-table-column>
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
</el-table-column>
@@ -948,10 +972,8 @@ async function loadLeafOrders(page: number) {
page_size: leafOrdersPageSize.value,
}
if (d.mode === 'dept') {
p.dept_ids = String(d.dept_id)
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
Object.assign(p, buildQueryParams())
p.table_row_dept_id = d.dept_id
} else {
Object.assign(p, buildQueryParams())
p.appt_channel_value = d.appt_channel_value
@@ -1981,6 +2003,21 @@ onMounted(async () => {
.cs-muted { color: var(--cs-muted); }
.cs-patient-cell {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.cs-patient-cell__name {
color: var(--cs-ink, #0f172a);
}
.cs-patient-cell__meta {
font-size: 12px;
}
/* ============ Element Plus overrides (scoped via deep) ============ */
.cs-page {
:deep(.el-input__wrapper),
+67
View File
@@ -181,9 +181,44 @@
<template #default="{ row }">¥ {{ formatDoctorDailyMoney(row.deal_amount) }}</template>
</el-table-column>
<el-table-column prop="deal_order_count" label="接诊诊单" min-width="88" align="right" />
<el-table-column
prop="appointment_total"
label="总挂号"
min-width="88"
align="right"
>
<template #header>
<span>总挂号</span>
<el-tooltip
effect="dark"
placement="top"
content="区间内该医生 doctor_appointment 全部状态合计(已预约/已完成/已过号/已取消);选部门时仅计经手医助归属在所选部门子树的挂号(与业绩看板「预约诊单」医助口径一致)"
>
<el-icon class="doctor-daily-th__hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="appointment_completed" label="挂号完成" min-width="88" align="right" />
<el-table-column prop="appointment_missed" label="过号" min-width="72" align="right" />
<el-table-column prop="appointment_cancelled" label="取消" min-width="72" align="right" />
<el-table-column
prop="appointment_conversion_rate"
label="挂号率"
min-width="92"
align="right"
>
<template #header>
<span>挂号率</span>
<el-tooltip
effect="dark"
placement="top"
content="挂号率 = 成交单数 / 总挂号数 × 100;总挂号为 0 时显示「—」"
>
<el-icon class="doctor-daily-th__hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">{{ formatDoctorDailyRate(row.appointment_conversion_rate) }}</template>
</el-table-column>
</el-table>
</el-card>
<el-empty v-else description="当前账号无医生统计权限" />
@@ -2078,6 +2113,10 @@ function buildDoctorDailyRequestParams(): Record<string, any> {
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
// resolvePrimaryDepts admin_dept
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (customRange.value?.[0] && customRange.value?.[1]) {
p.start_date = customRange.value[0]
p.end_date = customRange.value[1]
@@ -2096,6 +2135,15 @@ function formatDoctorDailyMoney(n: number | string | undefined) {
return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** 挂号率展示:null 或非有限数显示「—」;保留 2 位小数 + % 后缀 */
function formatDoctorDailyRate(v: number | string | null | undefined): string {
if (v === null || v === undefined || v === '') return '—'
const n = Number(v)
if (!Number.isFinite(n)) return '—'
return `${n.toFixed(2)}%`
}
function getDoctorDailySummaries(param: { columns: any[] }) {
const t = doctorDailyTotal.value
const sums: string[] = []
@@ -2118,6 +2166,9 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
case 'deal_order_count':
sums[index] = String(t.deal_order_count ?? 0)
break
case 'appointment_total':
sums[index] = String(t.appointment_total ?? 0)
break
case 'appointment_completed':
sums[index] = String(t.appointment_completed ?? 0)
break
@@ -2127,6 +2178,9 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
case 'appointment_cancelled':
sums[index] = String(t.appointment_cancelled ?? 0)
break
case 'appointment_conversion_rate':
sums[index] = formatDoctorDailyRate(t.appointment_conversion_rate)
break
default:
sums[index] = ''
}
@@ -4100,6 +4154,19 @@ onMounted(async () => {
background: var(--yj-accent-soft) !important;
font-size: 13px;
}
.doctor-daily-th__hint {
margin-left: 4px;
color: var(--yj-muted);
cursor: help;
vertical-align: -2px;
font-size: 13px;
transition: color 0.15s;
&:hover {
color: var(--yj-brand);
}
}
}
.filter-surface {
@@ -293,9 +293,12 @@ const summaryCards: MetricCard[] = [
{ key: 'interview_count', label: '面诊', type: 'count' },
{ key: 'order_amount', label: '订单金额', type: 'money' },
{ key: 'account_cost', label: '账户消耗', type: 'money' },
{ key: 'total_open_rate', label: '总开口率', type: 'percent' },
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent' },
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent' },
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
{ key: 'cash_cost', label: '现金成本', type: 'money' },
@@ -313,6 +316,7 @@ const tableColumns: MetricColumn[] = [
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
{ key: 'total_open_rate', label: '总开口率', type: 'percent', minWidth: 100 },
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
+79 -1
View File
@@ -509,6 +509,32 @@
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="completeDialogVisible"
title="完成问诊"
width="420px"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
<div class="complete-dialog">
<p class="complete-dialog-tip">是否添加医生备注</p>
<el-input
v-if="canAddCompleteNote"
v-model="completeNoteContent"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="填写完成备注,将追加到医生备注时间轴"
/>
</div>
<template #footer>
<el-button @click="completeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="completeSubmitting" @click="submitComplete">确认完成</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -518,7 +544,9 @@ import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { addDoctorNote } from '@/api/patient'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import {
formatDiabetesDiscoveryDisplay,
isDiabetesDiscoveryFilled
@@ -797,6 +825,12 @@ const ensureDetailDictOptions = async () => {
const editRef = ref()
const prescriptionRef = ref()
const chatDialogRef = ref()
const COMPLETE_NOTE_PERMISSION = ['doctor.appointment/addDoctorNote']
const completeDialogVisible = ref(false)
const completeSubmitting = ref(false)
const completeNoteContent = ref('')
const completeRow = ref<any>(null)
const canAddCompleteNote = computed(() => hasPermission(COMPLETE_NOTE_PERMISSION))
//
const qrcodeDialogVisible = ref(false)
@@ -866,8 +900,14 @@ const handleCancel = async (row: any) => {
//
const handleComplete = async (row: any) => {
completeRow.value = row
completeNoteContent.value = ''
if (canAddCompleteNote.value) {
completeDialogVisible.value = true
return
}
try {
await feedback.confirm('确认患者已就诊完成')
await feedback.confirm('是否添加医生备注')
await completeAppointment({ id: row.id })
feedback.msgSuccess('操作成功')
loadData()
@@ -875,6 +915,37 @@ const handleComplete = async (row: any) => {
//
}
}
const submitComplete = async () => {
const row = completeRow.value
if (!row?.id || completeSubmitting.value) return
completeSubmitting.value = true
try {
const note = completeNoteContent.value.trim()
await completeAppointment({ id: row.id })
let noteFailureMsg = ''
if (canAddCompleteNote.value && note) {
const diagnosisId = row.patient_id || row.diagnosis_id
if (diagnosisId) {
try {
await addDoctorNote({ diagnosis_id: Number(diagnosisId), content: note })
} catch (e) {
noteFailureMsg = '问诊已完成,但备注未保存'
}
} else {
noteFailureMsg = '问诊已完成,但该预约没有关联诊单,备注未保存'
}
}
noteFailureMsg ? feedback.msgWarning(noteFailureMsg) : feedback.msgSuccess('操作成功')
completeDialogVisible.value = false
completeNoteContent.value = ''
completeRow.value = null
loadData()
} catch (e: any) {
feedback.msgError(e?.msg || e?.message || '操作失败')
} finally {
completeSubmitting.value = false
}
}
//
const handleEditPatient = (row: any) => {
@@ -1330,6 +1401,13 @@ onUnmounted(() => {
color: var(--el-color-danger);
}
.complete-dialog-tip {
margin: 0 0 12px;
color: var(--el-text-color-primary);
font-size: 14px;
line-height: 22px;
}
.qrcode-content {
padding: 16px 0;
min-height: 200px;
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,12 @@
{{ assistantLogCellText(row, 'to') }}
</template>
</el-table-column>
<el-table-column label="继承" width="72" align="center">
<template #default="{ row }">
<el-tag v-if="Number(row.is_inherit) === 1" type="success" size="small"></el-tag>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="快照·业务单创建人" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
{{ relatedPoCreatorCell(row) }}
@@ -147,7 +147,7 @@ const props = withDefaults(defineProps<Props>(), {
const caseTypeLabel = computed(() => {
const d: any = props.diag || {}
return d.consultation_type === 'follow_up' ? '复诊' : '诊'
return d.consultation_type === 'follow_up' ? '复诊' : '诊'
})
const textOf = makeTextOf(() => props.diag || {})
@@ -16,7 +16,7 @@
预约{{ apt?.appointment_date }} {{ apt?.appointment_time }} ·
{{ formatPeriod(apt?.period) }}
</div>
<div>医生{{ apt?.doctor_name || '—' }} 医助{{ apt?.assistant_name || '—' }}</div>
<div>医生{{ apt?.doctor_name || '—' }} 客服{{ apt?.assistant_name || '—' }}</div>
<div>
状态{{ apt?.status_desc || '—' }} ·
{{ apt?.has_prescription ? '已开方' : '未开方' }}
+50 -1
View File
@@ -272,6 +272,41 @@
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="渠道来源" prop="create_source">
<el-select
v-model="formData.create_source"
placeholder="请选择渠道来源"
class="w-full"
:popper-options="{ strategy: 'fixed' }"
popper-class="high-z-index"
>
<el-option
v-for="item in createSourceOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="统计端就诊卡" prop="show_card">
<el-switch
v-model="formData.show_card"
:active-value="1"
:inactive-value="0"
active-text="显示"
inactive-text="隐藏"
inline-prompt
/>
<span class="form-tips ml-3">
关闭后该就诊卡将不会出现在小程序统计端
</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
@@ -855,9 +890,19 @@ const formData = ref({
remark: '',
current_medications: '',
status: 1,
create_source: 'admin',
show_card: 1,
external_userid: ''
})
/** 就诊卡创建来源(与 zyt_tcm_diagnosis.create_source 一致) */
const createSourceOptions = [
{ value: '', label: '未设置' },
{ value: 'admin', label: '后台建档' },
{ value: 'mnp', label: '小程序完整建档' },
{ value: 'mnp_daily', label: '小程序手机号快捷建档' }
]
//
const originalPhone = ref('')
const originalIdCard = ref('')
@@ -1127,6 +1172,8 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
data.id_card = maskIdCard(data.id_card || '')
formData.value = data
formData.value.create_source = data.create_source ?? ''
formData.value.show_card = Number(data.show_card) === 0 ? 0 : 1
const y = data.diabetes_discovery_year
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
}
@@ -1215,7 +1262,7 @@ const handleSubmit = async () => {
}
const handleViewCase = (row: any) => {
prescriptionRef.value?.openById(row.id)
prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true })
}
//
@@ -1288,6 +1335,8 @@ const handleClose = () => {
remark: '',
current_medications: '',
status: 1,
create_source: 'admin',
show_card: 1,
external_userid: ''
}
+12 -2
View File
@@ -456,6 +456,10 @@
/>
</el-select>
</el-form-item>
<el-form-item label="继承">
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
</el-form-item>
<el-form-item v-if="assignMode === 'batch'" label="已选择">
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
</el-form-item>
@@ -1340,12 +1344,14 @@ const assignLoading = ref(false)
const assignMode = ref<'batch' | 'single'>('batch')
const currentRow = ref<any>(null)
const assignForm = ref({
assistant_id: null as number | null
assistant_id: null as number | null,
is_inherit: false
})
function resetAssignDialog() {
batchAssignIds.value = []
assignForm.value.assistant_id = null
assignForm.value.is_inherit = false
}
const handleSelectionChange = (selection: any[]) => {
@@ -1372,6 +1378,7 @@ const handleSingleAssign = (row: any) => {
batchAssignIds.value = []
//
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignForm.value.is_inherit = false
assignDialogVisible.value = true
}
@@ -1384,6 +1391,7 @@ const handleBatchAssign = () => {
assignMode.value = 'batch'
currentRow.value = null
assignForm.value.assistant_id = null
assignForm.value.is_inherit = false
batchAssignIds.value = [...selectedIds.value]
assignDialogVisible.value = true
}
@@ -1400,7 +1408,8 @@ const handleConfirmAssign = async () => {
//
await tcmDiagnosisAssign({
id: currentRow.value.id,
assistant_id: assignForm.value.assistant_id
assistant_id: assignForm.value.assistant_id,
is_inherit: assignForm.value.is_inherit ? 1 : 0
})
} else {
// 使 id
@@ -1413,6 +1422,7 @@ const handleConfirmAssign = async () => {
await tcmDiagnosisAssign({
id,
assistant_id: assignForm.value.assistant_id!,
is_inherit: assignForm.value.is_inherit ? 1 : 0
})
}
selectedIds.value = []
+8 -2
View File
@@ -364,6 +364,10 @@
/>
</el-select>
</el-form-item>
<el-form-item label="继承">
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
</el-form-item>
<el-form-item label="诊单信息">
<div class="dh5-form-tip">
<div>患者{{ currentRow?.patient_name }}</div>
@@ -1080,10 +1084,11 @@ const runAction = (cmd: string) => {
const assignDialogVisible = ref(false)
const assignLoading = ref(false)
const currentRow = ref<any>(null)
const assignForm = ref({ assistant_id: null as number | null })
const assignForm = ref({ assistant_id: null as number | null, is_inherit: false })
const handleSingleAssign = (row: any) => {
currentRow.value = row
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignForm.value.is_inherit = false
assignDialogVisible.value = true
}
const handleConfirmAssign = async () => {
@@ -1092,7 +1097,8 @@ const handleConfirmAssign = async () => {
try {
await tcmDiagnosisAssign({
id: currentRow.value.id,
assistant_id: assignForm.value.assistant_id
assistant_id: assignForm.value.assistant_id,
is_inherit: assignForm.value.is_inherit ? 1 : 0
})
assignDialogVisible.value = false
getLists()
+12 -3
View File
@@ -326,6 +326,10 @@
/>
</el-select>
</el-form-item>
<el-form-item label="继承">
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
</el-form-item>
<el-form-item v-if="assignMode === 'batch'" label="已选择">
<div class="text-gray-500">{{ selectedIds.length }} 条诊单记录</div>
</el-form-item>
@@ -1078,7 +1082,8 @@ const assignLoading = ref(false)
const assignMode = ref<'batch' | 'single'>('batch')
const currentRow = ref<any>(null)
const assignForm = ref({
assistant_id: null as number | null
assistant_id: null as number | null,
is_inherit: false
})
const handleSelectionChange = (selection: any[]) => {
@@ -1091,6 +1096,7 @@ const handleSingleAssign = (row: any) => {
currentRow.value = row
//
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignForm.value.is_inherit = false
assignDialogVisible.value = true
}
@@ -1103,6 +1109,7 @@ const handleBatchAssign = () => {
assignMode.value = 'batch'
currentRow.value = null
assignForm.value.assistant_id = null
assignForm.value.is_inherit = false
assignDialogVisible.value = true
}
@@ -1118,14 +1125,16 @@ const handleConfirmAssign = async () => {
//
await tcmDiagnosisAssign({
id: currentRow.value.id,
assistant_id: assignForm.value.assistant_id
assistant_id: assignForm.value.assistant_id,
is_inherit: assignForm.value.is_inherit ? 1 : 0
})
} else {
//
const promises = selectedIds.value.map(id =>
tcmDiagnosisAssign({
id,
assistant_id: assignForm.value.assistant_id
assistant_id: assignForm.value.assistant_id,
is_inherit: assignForm.value.is_inherit ? 1 : 0
})
)
await Promise.all(promises)
+203
View File
@@ -0,0 +1,203 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** SugarControl
**Generated:** 2026-05-28 14:58:06
**Category:** Healthcare App
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#0891B2` | `--color-primary` |
| Secondary | `#22D3EE` | `--color-secondary` |
| CTA/Accent | `#059669` | `--color-cta` |
| Background | `#ECFEFF` | `--color-background` |
| Text | `#164E63` | `--color-text` |
**Color Notes:** Calm cyan + health green
### Typography
- **Heading Font:** Atkinson Hyperlegible
- **Body Font:** Atkinson Hyperlegible
- **Mood:** accessible, readable, inclusive, WCAG, dyslexia-friendly, clear
- **Google Fonts:** [Atkinson Hyperlegible + Atkinson Hyperlegible](https://fonts.google.com/share?selection.family=Atkinson+Hyperlegible:wght@400;700)
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&display=swap');
```
### Spacing Variables
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
| `--space-md` | `16px` / `1rem` | Standard padding |
| `--space-lg` | `24px` / `1.5rem` | Section padding |
| `--space-xl` | `32px` / `2rem` | Large gaps |
| `--space-2xl` | `48px` / `3rem` | Section margins |
| `--space-3xl` | `64px` / `4rem` | Hero padding |
### Shadow Depths
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #059669;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #0891B2;
border: 2px solid #0891B2;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #ECFEFF;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #0891B2;
outline: none;
box-shadow: 0 0 0 3px #0891B220;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Accessible & Ethical
**Keywords:** High contrast, large text (16px+), keyboard navigation, screen reader friendly, WCAG compliant, focus state, semantic
**Best For:** Government, healthcare, education, inclusive products, large audience, legal compliance, public
**Key Effects:** Clear focus rings (3-4px), ARIA labels, skip links, responsive design, reduced motion, 44x44px touch targets
### Page Pattern
**Pattern Name:** Social Proof-Focused
- **CTA Placement:** Above fold
- **Section Order:** Hero > Features > CTA
---
## Anti-Patterns (Do NOT Use)
- ❌ Bright neon colors
- ❌ Motion-heavy animations
- ❌ AI purple/pink gradients
### Additional Forbidden Patterns
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
- ❌ **Instant state changes** — Always use transitions (150-300ms)
- ❌ **Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile
@@ -8,11 +8,14 @@ use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
/**
* 医生日统计(系统/手动开方、成交、挂号状态)
* 医生日统计(系统/手动开方、成交、挂号状态、总挂号、挂号率
*
* - GET stats.doctorDailyStats/overview
* ?start_date=&end_date=&channel_code=
* 不传/忽略 dept_ids(医生统计不按展示部门检索);未传日期时由后端默认当日
* ?start_date=&end_date=&channel_code=&dept_ids=
* dept_ids 可传逗号串或数组;选中父级会自动展开全部子级(与业绩看板部门树同口径)
* 筛选口径:部门 admin_dept 命中的医助集合 该医助经手的挂号(COALESCE(a.assistant_id,u.assistant_id)/
* 订单(o.creator_id/ 处方(tcm_diagnosis.assistant_id),再按医生(rx.creator_id / a.doctor_id)汇总;
* 显式选部门时隐藏全 0 医生行。未传日期时由后端默认当日。
*/
class DoctorDailyStatsController extends BaseAdminController
{
@@ -345,6 +345,25 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('操作成功', $result);
}
/**
* 业务订单退款(须填写原因;关联收款单标记已退款)
*/
public function refund()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('refund');
$result = PrescriptionOrderLogic::refund(
(int) $params['id'],
(string) ($params['reason'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('退款成功', $result);
}
/**
* 甘草药管家:处方下单(CTM_PREVIEW CTM_SUBMIT_RECIPEL
*
@@ -560,6 +560,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$poIds = array_values(array_filter(array_map('intval', array_column($lists, 'id'))));
$paidSumByPo = [];
// 关联支付单数量:批量统计,避免后续逐行 count() 造成 N+1 查询(导出/大列表明显变慢)
$linkCountByPo = [];
if ($poIds !== []) {
$links = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $poIds)
->field(['prescription_order_id', 'pay_order_id'])
@@ -569,6 +571,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
foreach ($links as $l) {
$poid = (int) ($l['prescription_order_id'] ?? 0);
$oid = (int) ($l['pay_order_id'] ?? 0);
if ($poid > 0) {
$linkCountByPo[$poid] = ($linkCountByPo[$poid] ?? 0) + 1;
}
if ($poid > 0 && $oid > 0) {
$byPo[$poid][] = $oid;
}
@@ -664,10 +669,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
$pid = (int) ($item['id'] ?? 0);
$item['linked_pay_order_count'] = (int) PrescriptionOrderPayOrder::where(
'prescription_order_id',
$pid
)->count();
$item['linked_pay_order_count'] = (int) ($linkCountByPo[$pid] ?? 0);
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
$item['deposit_min_amount'] = $depMin;
$item['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
@@ -776,11 +778,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_channel' => '渠道',
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
'export_medication_form' => '药品形态',
'export_service_package' => '服务套餐',
'export_medication_days' => '天数',
'export_amount' => '总金额',
'export_paid_amount' => '已付金额',
'export_agency_collect' => '代收金额',
'export_tracking_number' => '快递单号',
'export_sign_time' => '签收日期',
'export_supply_mode' => '甘草还是自营',
'export_gancao_prescription_cost' => '处方成本',
'export_first_visit_assistant' => '初诊医助',
@@ -10,13 +10,20 @@ use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;**不按展示部门收窄**(始终按全部「中心」展示树,再套数据权限)。
* 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;当显式传入 dept_ids 时,
* **以部门下的医助为入口**收窄聚合(部门 admin_dept 命中的医助集合 该医助经手的挂号 / 订单 / 处方 医生)。
*
* 医生范围:<b>admin_role.role_id = 1</b> 且管理员 <b>未软删</b>delete_time 为空);再按账号「数据范围」收窄。
*
* - 系统/手动开方:tcm_prescription.prescription_date [start,end];渠道/标签与业绩渠道列同源 EXISTS / 诊单标签
* - 成交:订单 create_time、排除履约 4/9/10,按处方 creator_id;渠道/标签同 sumPerformance 口径
* - 挂号:appointment_date [start,end];选渠道时挂号 channels 命中字典值;标签渠道时 patient_id 标签诊单集
* - 部门:dept_ids YejiStatsLogic::resolveSharedYejiFilterContext 解析后透出 adminToPrimary(含全部子级展开后的 admin 集合),
* 下方聚合在挂号 / 订单 / 处方表上用以下口径筛选医助:
* · 挂号:COALESCE(NULLIF(a.assistant_id,0), NULLIF(u.assistant_id,0)) 医助集合
* · 订单:o.creator_id 医助集合(与业绩归属同源)
* · 处方:tcm_diagnosis.assistant_id 医助集合(rx.diagnosis_id JOIN tcm_diagnosis
* 医生集合不被部门收窄(医生通常不挂在「中心」部门);展示行最终在前端按所有医生输出,仅在 dept_filter 激活时隐藏「全 0」医生。
*/
class DoctorDailyStatsLogic
{
@@ -24,6 +31,7 @@ class DoctorDailyStatsLogic
* @param array{
* start_date?:string,
* end_date?:string,
* dept_ids?:int[]|string,
* channel_code?:string,
* tag_id?:string,
* doctor_id?:int|string
@@ -33,11 +41,9 @@ class DoctorDailyStatsLogic
*/
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
// 医生统计不参与「展示部门」检索:忽略 dept_ids,避免与业绩表部门筛选联动
$paramsForCtx = $params;
unset($paramsForCtx['dept_ids']);
$ctx = YejiStatsLogic::resolveSharedYejiFilterContext($paramsForCtx, $viewerAdminId, $viewerAdminInfo);
// dept_ids 透传至共享上下文:未传时仍按默认「中心」树解析(仅用于挂号率默认 0 等兜底);
// 显式传入时由下方 $deptScopedAdminIds 分支用 adminToPrimary 取出医助集合,并下推到三类聚合作为「经手医助」筛选。
$ctx = YejiStatsLogic::resolveSharedYejiFilterContext($params, $viewerAdminId, $viewerAdminInfo);
$startDate = $ctx['startDate'];
$endDate = $ctx['endDate'];
$startTs = $ctx['startTs'];
@@ -49,6 +55,7 @@ class DoctorDailyStatsLogic
$tagFallback = $ctx['tagFallback'];
$filterDoctorId = (int) ($params['doctor_id'] ?? 0);
$deptFilterActive = self::hasExplicitDeptIds($params['dept_ids'] ?? null);
$doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo);
@@ -56,6 +63,24 @@ class DoctorDailyStatsLogic
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
}
// 部门下医助集合(含全部子级展开后的 admin_dept 命中者);未显式选部门时不参与筛选 → null。
// 显式选部门但集合为空 ⇒ 该部门下无可见医助,直接返回空结果。
$deptScopedAdminIds = null;
if ($deptFilterActive) {
$deptScopedAdminIds = array_values(array_unique(array_map(
'intval',
array_keys($ctx['adminToPrimary'] ?? [])
)));
if ($deptScopedAdminIds === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => [],
'total' => self::emptyTotals(),
];
}
}
if ($doctorIds === []) {
return [
'start_date' => $startDate,
@@ -73,7 +98,8 @@ class DoctorDailyStatsLogic
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback
$tagFallback,
$deptScopedAdminIds
);
$orderMap = self::loadOrderAggregates(
$startTs,
@@ -83,7 +109,8 @@ class DoctorDailyStatsLogic
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback
$tagFallback,
$deptScopedAdminIds
);
$apptMap = self::loadAppointmentAggregates(
$startDate,
@@ -91,7 +118,8 @@ class DoctorDailyStatsLogic
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds
$tagDiagIds,
$deptScopedAdminIds
);
$adminRows = Admin::whereIn('id', $doctorIds)
@@ -109,9 +137,10 @@ class DoctorDailyStatsLogic
foreach ($doctorIds as $aid) {
$rx = $rxMap[$aid] ?? ['system' => 0, 'manual' => 0];
$ord = $orderMap[$aid] ?? ['amount' => 0.0, 'count' => 0];
$ap = $apptMap[$aid] ?? ['completed' => 0, 'missed' => 0, 'cancelled' => 0];
$ap = $apptMap[$aid] ?? ['completed' => 0, 'missed' => 0, 'cancelled' => 0, 'total' => 0];
$cnt = (int) $ord['count'];
$amt = round((float) $ord['amount'], 2);
$apTotal = (int) ($ap['total'] ?? 0);
$rows[] = [
'admin_id' => $aid,
'doctor_name' => $nameById[$aid] ?? ('#' . $aid),
@@ -120,12 +149,26 @@ class DoctorDailyStatsLogic
'deal_amount' => $amt,
'deal_order_count' => $cnt,
'avg_deal_amount' => $cnt > 0 ? round($amt / $cnt, 2) : null,
'appointment_total' => $apTotal,
'appointment_completed' => (int) $ap['completed'],
'appointment_missed' => (int) $ap['missed'],
'appointment_cancelled' => (int) $ap['cancelled'],
// 挂号率 = 成交单数 / 总挂号数 × 100;总挂号为 0 时返回 null(前端展示「—」)。
'appointment_conversion_rate' => $apTotal > 0 ? round($cnt / $apTotal * 100, 2) : null,
];
}
// 显式部门筛选时隐藏「该部门无任何关联」的医生,避免列出大量全 0 行。
if ($deptFilterActive) {
$rows = array_values(array_filter($rows, static function (array $r): bool {
return (int) ($r['system_prescription_count'] ?? 0) > 0
|| (int) ($r['manual_prescription_count'] ?? 0) > 0
|| (float) ($r['deal_amount'] ?? 0) > 0
|| (int) ($r['deal_order_count'] ?? 0) > 0
|| (int) ($r['appointment_total'] ?? 0) > 0;
}));
}
usort($rows, static function (array $a, array $b): int {
if (($a['deal_amount'] ?? 0) != ($b['deal_amount'] ?? 0)) {
return ($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0);
@@ -142,6 +185,31 @@ class DoctorDailyStatsLogic
];
}
/**
* 是否显式传入 dept_ids(含 1 个以上正整数即视为显式)。与 YejiStatsLogic::hasExplicitDeptIdsParam 同口径。
*
* @param mixed $raw
*/
private static function hasExplicitDeptIds($raw): bool
{
if (\is_string($raw) && trim($raw) !== '') {
foreach (explode(',', $raw) as $p) {
if ((int) trim($p) > 0) {
return true;
}
}
}
if (\is_array($raw)) {
foreach ($raw as $v) {
if ((int) $v > 0) {
return true;
}
}
}
return false;
}
/**
* 医生角色(role_id=1)、管理员未删除、且在数据范围内的 admin_id。
*
@@ -186,9 +254,11 @@ class DoctorDailyStatsLogic
'deal_amount' => 0.0,
'deal_order_count' => 0,
'avg_deal_amount' => null,
'appointment_total' => 0,
'appointment_completed' => 0,
'appointment_missed' => 0,
'appointment_cancelled' => 0,
'appointment_conversion_rate' => null,
];
}
@@ -205,6 +275,7 @@ class DoctorDailyStatsLogic
$t['manual_prescription_count'] += (int) ($r['manual_prescription_count'] ?? 0);
$t['deal_amount'] += (float) ($r['deal_amount'] ?? 0);
$t['deal_order_count'] += (int) ($r['deal_order_count'] ?? 0);
$t['appointment_total'] += (int) ($r['appointment_total'] ?? 0);
$t['appointment_completed'] += (int) ($r['appointment_completed'] ?? 0);
$t['appointment_missed'] += (int) ($r['appointment_missed'] ?? 0);
$t['appointment_cancelled'] += (int) ($r['appointment_cancelled'] ?? 0);
@@ -212,6 +283,9 @@ class DoctorDailyStatsLogic
$t['deal_amount'] = round((float) $t['deal_amount'], 2);
$dc = (int) $t['deal_order_count'];
$t['avg_deal_amount'] = $dc > 0 ? round((float) $t['deal_amount'] / $dc, 2) : null;
$apTotal = (int) $t['appointment_total'];
// 合计行挂号率:行行加总后再统一计算,与汇总后的 成交单数 / 总挂号数 对齐。
$t['appointment_conversion_rate'] = $apTotal > 0 ? round($dc / $apTotal * 100, 2) : null;
return $t;
}
@@ -220,6 +294,7 @@ class DoctorDailyStatsLogic
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 tcm_diagnosis.assistant_id IN (...) 约束
*
* @return array<int, array{system:int, manual:int}>
*/
@@ -231,11 +306,15 @@ class DoctorDailyStatsLogic
bool $channelFilterActive,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback
bool $tagFallback,
?array $deptScopedAdminIds = null
): array {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
return [];
}
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
return [];
}
$query = Db::name('tcm_prescription')
->alias('rx')
@@ -245,6 +324,12 @@ class DoctorDailyStatsLogic
->whereIn('rx.creator_id', $doctorIds)
->where('rx.diagnosis_id', '>', 0);
// 部门 → 医助:仅保留经手医助归属在所选部门子树的处方(通过诊单 assistant_id 关联)
if ($deptScopedAdminIds !== null) {
$query->join('tcm_diagnosis dg', 'dg.id = rx.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereIn('dg.assistant_id', $deptScopedAdminIds);
}
self::applyPrescriptionChannelTagFilter(
$query,
'rx',
@@ -280,6 +365,7 @@ class DoctorDailyStatsLogic
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 o.creator_id IN (...) 约束(与业绩归属同源)
*
* @return array<int, array{amount: float, count: int}>
*/
@@ -291,11 +377,15 @@ class DoctorDailyStatsLogic
bool $channelFilterActive,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback
bool $tagFallback,
?array $deptScopedAdminIds = null
): array {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
return [];
}
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
return [];
}
$q = Db::name('tcm_prescription_order')
->alias('o')
@@ -306,6 +396,11 @@ class DoctorDailyStatsLogic
$q->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0);
// 部门 → 医助:业绩归属同源(o.creator_id 即订单创建医助)
if ($deptScopedAdminIds !== null) {
$q->whereIn('o.creator_id', $deptScopedAdminIds);
}
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$apTable = self::tableWithPrefix('doctor_appointment');
@@ -423,8 +518,10 @@ class DoctorDailyStatsLogic
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加
* COALESCE(NULLIF(a.assistant_id,0), NULLIF(u.assistant_id,0)) IN (...) 约束
*
* @return array<int, array{completed:int, missed:int, cancelled:int}>
* @return array<int, array{completed:int, missed:int, cancelled:int, total:int}>
*/
private static function loadAppointmentAggregates(
string $startDate,
@@ -432,29 +529,54 @@ class DoctorDailyStatsLogic
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds
?array $tagDiagIds,
?array $deptScopedAdminIds = null
): array {
$q = Db::name('doctor_appointment')
->whereBetween('appointment_date', [$startDate, $endDate])
->whereIn('doctor_id', $doctorIds);
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
return [];
}
$needsDiagJoin = $deptScopedAdminIds !== null;
if ($needsDiagJoin) {
$q = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.doctor_id', $doctorIds)
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
$aliasPrefix = 'a.';
} else {
$q = Db::name('doctor_appointment')
->whereBetween('appointment_date', [$startDate, $endDate])
->whereIn('doctor_id', $doctorIds);
$aliasPrefix = '';
}
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$q->whereIn('channels', $strVals);
$q->whereIn($aliasPrefix . 'channels', $strVals);
} elseif ($channelFilterActive && $tagDiagIds !== null) {
if ($tagDiagIds === []) {
return [];
}
$q->whereIn('patient_id', $tagDiagIds);
$q->whereIn($aliasPrefix . 'patient_id', $tagDiagIds);
}
// 部门 → 医助:与业绩看板 consultEffectiveAssistantSql 同口径 —— 优先挂号创建人,回退诊单医助
if ($needsDiagJoin) {
$effIds = array_map('intval', $deptScopedAdminIds);
$inList = implode(',', $effIds);
$q->whereRaw('COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0)) IN (' . $inList . ')');
}
// total = 当前筛选下挂号总数(含 status=1 已预约/2 已取消/3 已完成/4 已过号),用于计算「挂号率」。
$q->field([
'doctor_id',
Db::raw('SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS completed'),
Db::raw('SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS missed'),
Db::raw('SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS cancelled'),
])->group('doctor_id');
$aliasPrefix . 'doctor_id',
Db::raw('COUNT(*) AS total'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 3 THEN 1 ELSE 0 END) AS completed'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 4 THEN 1 ELSE 0 END) AS missed'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 2 THEN 1 ELSE 0 END) AS cancelled'),
])->group($aliasPrefix . 'doctor_id');
$out = [];
foreach ($q->select()->toArray() as $r) {
@@ -463,6 +585,7 @@ class DoctorDailyStatsLogic
continue;
}
$out[$id] = [
'total' => (int) ($r['total'] ?? 0),
'completed' => (int) ($r['completed'] ?? 0),
'missed' => (int) ($r['missed'] ?? 0),
'cancelled' => (int) ($r['cancelled'] ?? 0),
@@ -302,6 +302,7 @@ class SelfInputLogic extends BaseLogic
$entity['appointment_total_count'] = $appointmentTotalCount;
$entity['order_amount'] = $orderAmount;
$entity['account_cost'] = $accountCost;
$entity['total_open_rate'] = self::percent($totalOpenCount, $addFansCount);
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
File diff suppressed because it is too large Load Diff
@@ -114,6 +114,10 @@ class DiagnosisLogic extends BaseLogic
}
}
if (empty($params['create_source'])) {
$params['create_source'] = 'admin';
}
$model = Diagnosis::create($params);
// 图片写入 doctor_note 表
@@ -559,6 +563,7 @@ class DiagnosisLogic extends BaseLogic
try {
$id = (int) ($params['id'] ?? 0);
$toAssistantId = (int) ($params['assistant_id'] ?? 0);
$isInherit = (int) ($params['is_inherit'] ?? 0) === 1 && $toAssistantId > 0 ? 1 : 0;
if ($id <= 0) {
self::setError('诊单不存在');
@@ -619,6 +624,7 @@ class DiagnosisLogic extends BaseLogic
'ip' => (string) ($req->ip() ?? ''),
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'is_inherit' => $isInherit,
'create_time' => $now,
]);
} catch (\Throwable $e) {
@@ -692,6 +698,9 @@ class DiagnosisLogic extends BaseLogic
$r['related_po_create_time_text'] = $rpct > 0 ? date('Y-m-d H:i:s', $rpct) : '';
$r['related_po_is_cleared_assistant_creator'] =
($rcpId > 0 && $fromId > 0 && $rcpId === $fromId) ? 1 : 0;
$inherit = (int) ($r['is_inherit'] ?? 0);
$r['is_inherit'] = $inherit;
$r['is_inherit_text'] = $inherit === 1 ? '是' : '否';
}
unset($r);
@@ -3019,13 +3028,14 @@ class DiagnosisLogic extends BaseLogic
return [];
}
// 根据诊断ID列表查询诊单详情
// 根据诊断ID列表查询诊单详情show_card=0 的诊单不在统计端就诊卡列表中出现)
$cardList = Diagnosis::whereIn('id', $viewRecords)
->where('delete_time', null)
->where('show_card', 1)
->field([
'id', 'patient_id', 'patient_name', 'gender', 'age',
'diagnosis_date', 'diagnosis_type', 'syndrome_type',
'status', 'create_time', 'update_time'
'status', 'create_source', 'create_time', 'update_time'
])
->order('create_time', 'desc')
->select()
@@ -185,6 +185,7 @@ class PrescriptionLogic
'bags_per_dose' => isset($raw['bags_per_dose']) ? (int) $raw['bags_per_dose'] : 1,
'times_per_day' => (int) ($raw['times_per_day'] ?? 3),
'usage_days' => (int) ($raw['usage_days'] ?? 7),
'prescription_name' => trim((string) ($raw['prescription_name'] ?? '')),
];
}
@@ -19,6 +19,7 @@ use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\service\DataScope\DataScopeService;
use app\common\service\ExpressTrackService;
use app\common\service\ExpressTrackingService;
use app\common\service\gancao\GancaoScmRecipelService;
use think\db\Query;
use think\facade\Config;
@@ -2533,8 +2534,8 @@ class PrescriptionOrderLogic
return $out;
}
/** 完成订单时可选的履约终态:3=已完成,7-12=业务结案类状态 */
private const COMPLETE_FULFILLMENT_TARGETS = [3, 7, 8, 9, 10, 11, 12];
/** 完成订单时可选的履约终态:3=已完成,7-12 业务结案类状态(退款 10 走独立 refund 接口) */
private const COMPLETE_FULFILLMENT_TARGETS = [3, 7, 8, 9, 11, 12];
/** 完成时是否按「实付=关联支付单金额」重算并清空诊单医助(与 clearDiagnosisAssistantOnComplete 的终态判断一致) */
private const COMPLETE_FULFILLMENT_TERMINAL = [3, 8, 9, 10, 11, 12];
@@ -2591,13 +2592,7 @@ class PrescriptionOrderLogic
$order->fulfillment_status = $targetFulfillmentStatus;
}
$refundedPayCount = 0;
try {
if ($targetFulfillmentStatus === 10 && $linkedPayOrderIds !== []) {
$refundedPayCount = Order::whereIn('id', $linkedPayOrderIds)
->whereNull('delete_time')
->update(['status' => 4]);
}
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
@@ -2613,9 +2608,6 @@ class PrescriptionOrderLogic
$logLine = '订单完成,状态变更为「' . $label . '」,实付金额更新为 ¥' . $linkedPayPaidTotal . $unassignSummary;
} else {
$logLine = '订单处理完成,状态变更为「' . $label . '」' . $unassignSummary;
if ($targetFulfillmentStatus === 10 && $refundedPayCount > 0) {
$logLine .= ',关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
}
}
self::writeLog(
$id,
@@ -2633,6 +2625,79 @@ class PrescriptionOrderLogic
return $out;
}
/**
* 业务订单退款:须填写原因;关联收款单标记为已退款,实付清零。
*
* @return array<string,mixed>|false
*/
public static function refund(int $id, string $reason, int $adminId, array $adminInfo)
{
self::$error = '';
$reason = trim($reason);
if ($reason === '') {
self::$error = '请填写退款原因';
return false;
}
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!$order) {
self::$error = '订单不存在';
return false;
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作';
return false;
}
$curFs = (int) $order->fulfillment_status;
if ($curFs !== 5 && $curFs !== 6) {
self::$error = '仅「已发货」或「已签收」状态可退款';
return false;
}
if ((int) $order->payment_slip_audit_status !== 1) {
self::$error = '支付单审核尚未通过,不可退款';
return false;
}
$linkedPayOrderIds = self::linkedPayOrderIdList($id);
$refundedPayCount = 0;
try {
if ($linkedPayOrderIds !== []) {
$refundedPayCount = Order::whereIn('id', $linkedPayOrderIds)
->whereNull('delete_time')
->update(['status' => 4]);
}
$order->fulfillment_status = 10;
$order->paid = 0;
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
$unassignSummary = self::clearDiagnosisAssistantOnComplete((int) $order->diagnosis_id);
$logLine = '订单退款,状态变更为「退款」;原因:' . $reason;
if ($refundedPayCount > 0) {
$logLine .= ';关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
}
$logLine .= ';已付总额清零' . $unassignSummary;
self::writeLog($id, $adminId, $adminInfo, 'refund', $logLine);
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
public static function fulfillmentStatusLabel(int $fs): string
{
$m = [
@@ -2672,6 +2737,94 @@ class PrescriptionOrderLogic
return 0;
}
/**
* 复诊导出「初诊医助」:同 diagnosis_id 下最早 is_follow_up=0 的业务单医助(非 prev_staff 医生名)
*
* @param array<int, array<string, mixed>> $diagById
* @return array<int, string> diagnosis_id => 医助姓名
*/
private static function batchFirstVisitAssistantNameByDiagnosis(array $diagIds, array $diagById): array
{
if ($diagIds === []) {
return [];
}
$firstRows = PrescriptionOrder::whereIn('diagnosis_id', $diagIds)
->whereNull('delete_time')
->where('is_follow_up', 0)
->field(['id', 'diagnosis_id', 'prescription_id', 'creator_id', 'create_time'])
->order('create_time', 'asc')
->order('id', 'asc')
->select()
->toArray();
$firstByDiag = [];
$rxIds = [];
foreach ($firstRows as $row) {
$did = (int) ($row['diagnosis_id'] ?? 0);
if ($did <= 0 || isset($firstByDiag[$did])) {
continue;
}
$firstByDiag[$did] = $row;
$rxId = (int) ($row['prescription_id'] ?? 0);
if ($rxId > 0) {
$rxIds[$rxId] = true;
}
}
$rxAssistantById = [];
if ($rxIds !== []) {
$rxAssistantById = Prescription::whereIn('id', array_keys($rxIds))
->whereNull('delete_time')
->column('assistant_id', 'id');
}
$adminIds = [];
foreach ($firstByDiag as $did => $row) {
$diag = $diagById[$did] ?? [];
$rxId = (int) ($row['prescription_id'] ?? 0);
$rxAst = $rxId > 0 ? (int) ($rxAssistantById[$rxId] ?? 0) : 0;
$aid = self::resolveAssistantAdminIdForPrescriptionOrderRow(
$rxAst,
(int) ($diag['assistant_id'] ?? 0)
);
if ($aid <= 0) {
$aid = (int) ($row['creator_id'] ?? 0);
}
if ($aid > 0) {
$adminIds[$aid] = true;
}
}
$nameByAdminId = [];
if ($adminIds !== []) {
$admins = Admin::whereIn('id', array_keys($adminIds))
->field(['id', 'name'])
->select()
->toArray();
foreach ($admins as $a) {
$nameByAdminId[(int) $a['id']] = trim((string) ($a['name'] ?? ''));
}
}
$out = [];
foreach ($firstByDiag as $did => $row) {
$diag = $diagById[$did] ?? [];
$rxId = (int) ($row['prescription_id'] ?? 0);
$rxAst = $rxId > 0 ? (int) ($rxAssistantById[$rxId] ?? 0) : 0;
$aid = self::resolveAssistantAdminIdForPrescriptionOrderRow(
$rxAst,
(int) ($diag['assistant_id'] ?? 0)
);
if ($aid <= 0) {
$aid = (int) ($row['creator_id'] ?? 0);
}
$out[$did] = $aid > 0 ? (string) ($nameByAdminId[$aid] ?? '') : '';
}
return $out;
}
/**
* 导出用:管理员在 admin_dept 中的部门路径(多部门「;」、路径内「 / 」;业务订单导出取 creator_id
*/
@@ -2693,6 +2846,36 @@ class PrescriptionOrderLogic
return $pathSegments === [] ? '' : implode('', $pathSegments);
}
/**
* 导出用:服务套餐(与列表/详情 formatServicePackage 同口径,按 dict_data type_value=server_order 解析)
* 入参可能是逗号分隔字符串或数组;空串返回空,避免 Excel 显示「—」占位。
*
* @param mixed $value zyt_tcm_prescription_order.service_package
* @param array<string, string> $packageNameByValue dict_data server_order value=>name 字典缓存
*/
public static function formatServicePackageForExport($value, array $packageNameByValue): string
{
if ($value === null || $value === '' || $value === false) {
return '';
}
$items = [];
if (\is_array($value)) {
$items = $value;
} else {
$items = explode(',', (string) $value);
}
$names = [];
foreach ($items as $raw) {
$key = trim((string) $raw);
if ($key === '') {
continue;
}
$names[] = (string) ($packageNameByValue[$key] ?? $packageNameByValue[(string) (int) $key] ?? $key);
}
return $names === [] ? '' : implode('、', $names);
}
/**
* 导出用:药品形态(丸剂 / 饮片+代煎|自煎等)
*
@@ -2944,6 +3127,55 @@ class PrescriptionOrderLogic
}
}
// 服务套餐字典:与前端 formatServicePackage 同口径(dict_data type_value=server_order;只取启用项做兜底取原值)
$packageNameByValue = [];
$hasAnyPackage = false;
foreach ($lists as $row) {
$pkRaw = $row['service_package'] ?? '';
if (\is_array($pkRaw)) {
if ($pkRaw !== []) {
$hasAnyPackage = true;
break;
}
} elseif (trim((string) $pkRaw) !== '') {
$hasAnyPackage = true;
break;
}
}
if ($hasAnyPackage) {
try {
$packageNameByValue = DictData::where('type_value', 'server_order')->column('name', 'value');
} catch (\Throwable) {
$packageNameByValue = [];
}
}
// 签收时间:纯读物流库(express_tracking / express_trace),不在导出阶段发起任何快递100 HTTP。
// 签收时间的落库由 `php think tcm:backfill-sign-time` 与 `express:auto-update` 定时任务负责,
// 导出只读脚本/定时任务处理好的结果——避免逐单 HTTP 让导出变慢、耗时不可控。
/** @var array<int, int> $signTsByPoId order_id => Unix 秒 */
$signTsByPoId = [];
if ($poIds !== []) {
$signItems = [];
foreach ($lists as $row) {
$oid = (int) ($row['id'] ?? 0);
if ($oid <= 0) {
continue;
}
$signItems[] = [
'order_id' => $oid,
'tracking_number' => trim((string) ($row['tracking_number'] ?? '')),
];
}
try {
$signTsByPoId = ExpressTrackingService::batchResolveSignUnixFromDbForOrders($signItems);
} catch (\Throwable $e) {
Log::warning('appendPrescriptionOrderListExportRows batch sign_time failed: ' . $e->getMessage());
$signTsByPoId = [];
}
}
$firstVisitAssistantByDiag = self::batchFirstVisitAssistantNameByDiagnosis($diagIdList, $diagById);
$assistantDeptCache = [];
foreach ($lists as &$item) {
@@ -2982,6 +3214,10 @@ class PrescriptionOrderLogic
}
$item['export_medication_form'] = self::formatMedicationFormForExport(\is_array($rx) ? $rx : []);
$item['export_service_package'] = self::formatServicePackageForExport(
$item['service_package'] ?? '',
$packageNameByValue
);
$item['export_channel'] = (string) ($item['service_channel'] ?? '');
$apptIdResolved = $resolvedApptIdByPoId[$poId] ?? 0;
@@ -3000,6 +3236,8 @@ class PrescriptionOrderLogic
$item['export_agency_collect'] = number_format(round($amt - $paid, 2), 2, '.', '');
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
$signTs = $poId > 0 ? (int) ($signTsByPoId[$poId] ?? 0) : 0;
$item['export_sign_time'] = $signTs > 0 ? date('Y-m-d', $signTs) : '';
$isGc = trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== '';
$item['export_supply_mode'] = $isGc ? '甘草' : '自营';
// 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出)
@@ -3013,7 +3251,9 @@ class PrescriptionOrderLogic
}
$isFu = (int) ($item['is_follow_up'] ?? 0) === 1;
$item['export_first_visit_assistant'] = $isFu ? (string) ($item['prev_staff'] ?? '') : '';
$item['export_first_visit_assistant'] = $isFu
? (string) ($firstVisitAssistantByDiag[(int) ($item['diagnosis_id'] ?? 0)] ?? '')
: '';
$hit = (int) ($item['po_assign_snapshot_hit'] ?? 0);
$rel = (int) ($item['po_assign_is_release'] ?? 0);
@@ -33,6 +33,7 @@ class DiagnosisValidate extends BaseValidate
'age' => 'require|number|between:0,150',
'diagnosis_type' => 'require',
'status' => 'in:0,1',
'create_source' => 'max:32',
'current_medications' => 'max:2000',
'tongue_images' => 'array',
'report_files' => 'array',
@@ -57,6 +58,7 @@ class DiagnosisValidate extends BaseValidate
'age.between' => '年龄范围0-150',
'diagnosis_type.require' => '请选择诊断类型',
'status.in' => '状态参数错误',
'create_source.max' => '渠道来源长度不能超过32个字符',
'current_medications.max' => '在用药物最多2000个字符',
'diabetes_discovery_year.max' => '发现糖尿病患病史最多50个字符',
'start_date.date' => '开始日期格式不正确',
@@ -76,7 +78,7 @@ class DiagnosisValidate extends BaseValidate
public function sceneEdit()
{
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status']);
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'create_source']);
}
public function sceneId()
@@ -32,7 +32,8 @@ class PrescriptionOrderValidate extends BaseValidate
'remark_assistant' => 'max:500',
'action' => 'require|in:approve,reject',
'remark' => 'max:500',
'fulfillment_status' => 'require|integer|in:3,7,8,9,10,11,12',
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
'reason' => 'require|max:500',
'patient_name' => 'require|max:50',
'phone' => 'require|max:20',
'phone_tail' => 'max:20|regex:/^\\d*$/',
@@ -50,6 +51,7 @@ class PrescriptionOrderValidate extends BaseValidate
'patient_name.require' => '请输入患者姓名',
'phone.require' => '请输入手机号',
'phone_tail.regex' => '手机后四位仅支持数字',
'reason.require' => '请填写退款原因',
];
protected $scene = [
@@ -75,6 +77,7 @@ class PrescriptionOrderValidate extends BaseValidate
'linkPayOrder' => ['id', 'pay_order_id'],
'requestCompletion' => ['id'],
'complete' => ['id', 'fulfillment_status'],
'refund' => ['id', 'reason'],
'submitGancaoRecipel' => ['id'],
'previewGancaoRecipel' => ['id'],
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
+556 -16
View File
@@ -16,9 +16,16 @@ namespace app\api\controller;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\api\logic\tcm\DailyGamifyLogic;
use app\api\logic\tcm\DailyFamilyLikeLogic;
use app\api\logic\tcm\DailyShareLogic;
use app\api\logic\tcm\DailyPhoneLogic;
use app\api\logic\tcm\DailyDietAiLogic;
use app\adminapi\logic\ConfigLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\BloodRecord;
use app\common\model\tcm\DietRecord;
use app\common\model\tcm\ExerciseRecord;
/**
* 中医诊断控制器(供小程序调用)
@@ -31,7 +38,7 @@ class TcmController extends BaseApiController
* @notes 不需要登录的方法
* @var array
*/
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailyTrackingWindow', 'dailyTrackingNotes'];
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
/**
* @notes 获取患者签名(供小程序调用)
@@ -237,7 +244,8 @@ class TcmController extends BaseApiController
'treatment_principle' => $this->request->post('treatment_principle'),
'prescription' => $this->request->post('prescription'),
'doctor_advice' => $this->request->post('doctor_advice'),
'remark' => $this->request->post('remark')
'remark' => $this->request->post('remark'),
'create_source' => DailyPhoneLogic::CREATE_SOURCE_MNP,
];
// patient_id 可选:不传则创建时自动生成(新患者首张就诊卡)
try {
@@ -350,16 +358,13 @@ class TcmController extends BaseApiController
$startDate = (string) $this->request->get('start_date', '');
$endDate = (string) $this->request->get('end_date', '');
if ($diagnosisId <= 0) {
return $this->fail('诊单ID不能为空');
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$diagnosis = $check['diagnosis'];
try {
// 患者端越权校验:诊单必须存在;如传入 patient_id 则须与诊单匹配
$diagnosis = Diagnosis::where('id', $diagnosisId)->find();
if (!$diagnosis) {
return $this->fail('诊单不存在');
}
if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) {
return $this->fail('无权查看该诊单的日常记录');
}
@@ -393,15 +398,13 @@ class TcmController extends BaseApiController
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$patientId = (int) $this->request->get('patient_id', 0);
if ($diagnosisId <= 0) {
return $this->fail('诊单ID不能为空');
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$diagnosis = $check['diagnosis'];
try {
$diagnosis = Diagnosis::where('id', $diagnosisId)->find();
if (!$diagnosis) {
return $this->fail('诊单不存在');
}
if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) {
return $this->fail('无权查看该诊单的跟踪备注');
}
@@ -413,6 +416,28 @@ class TcmController extends BaseApiController
}
}
/**
* 解析小程序 form-urlencoded 提交的 JSON 字段
*
* @param mixed $raw
* @param string $arrayKey ThinkPHP 数组字段名,如 badges/a
* @return array
*/
private function decodeJsonPostField($raw, string $arrayKey): array
{
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
}
if (is_array($raw)) {
return $raw;
}
$arr = $this->request->post($arrayKey, []);
return is_array($arr) ? $arr : [];
}
/**
* @notes 校验当前登录患者是否拥有指定诊单(通过 diagnosis_view_records 关联)
* @param int $diagnosisId
@@ -447,6 +472,34 @@ class TcmController extends BaseApiController
return ['ok' => true, 'diagnosis' => $diagnosis];
}
/**
* @notes 日常记录-手机号快捷建档(无就诊卡时,已绑定手机号即可录入血糖)
*
* 路由:POST /api/tcm/dailyEnsurePhoneContext
*
* @return \think\response\Json
*/
public function dailyEnsurePhoneContext()
{
$sex = $this->request->post('sex', null);
$options = [];
if ($sex !== null && $sex !== '') {
$options['sex'] = (int) $sex;
}
$result = DailyPhoneLogic::ensureDailyContext((int) $this->userId, $options);
if (empty($result['ok'])) {
$data = [];
if (!empty($result['need_mobile'])) {
$data['need_mobile'] = 1;
}
return $this->fail($result['error'] ?? '无法开始录入', $data);
}
unset($result['ok'], $result['error'], $result['need_mobile']);
return $this->data($result);
}
/**
* @notes 日常记录-获取今日已录入的血糖血压记录(患者端)
*
@@ -570,7 +623,42 @@ class TcmController extends BaseApiController
];
if ($existing) {
$payload['id'] = $existing['id'];
// 分表单项更新时保留未提交字段,避免血糖/血压互相覆盖
$keepDecimal = function ($new, string $oldKey) use ($parseDecimal, $existing) {
if ($new !== null) {
return $new;
}
return $parseDecimal($existing[$oldKey] ?? null);
};
$keepInt = function ($new, string $oldKey) use ($parseInt, $existing) {
if ($new !== null) {
return $new;
}
return $parseInt($existing[$oldKey] ?? null);
};
$fasting = $keepDecimal($fasting, 'fasting_blood_sugar');
$postprandial = $keepDecimal($postprandial, 'postprandial_blood_sugar');
$other = $keepDecimal($other, 'other_blood_sugar');
$systolic = $keepInt($systolic, 'systolic_pressure');
$diastolic = $keepInt($diastolic, 'diastolic_pressure');
if ($remark === '') {
$remark = trim((string) ($existing['remark'] ?? ''));
}
$payload = [
'diagnosis_id' => $diagnosisId,
'patient_id' => (int) $diagnosis['patient_id'],
'record_date' => $todayStart,
'record_time' => date('H:i'),
'fasting_blood_sugar' => $fasting,
'postprandial_blood_sugar' => $postprandial,
'other_blood_sugar' => $other,
'systolic_pressure' => $systolic,
'diastolic_pressure' => $diastolic,
'remark' => $remark,
'source' => 1,
'id' => $existing['id'],
];
BloodRecord::update($payload);
return $this->success('已更新今日记录', ['id' => $existing['id']]);
}
@@ -630,6 +718,458 @@ class TcmController extends BaseApiController
}
}
/**
* @notes 获取稳糖乐园状态(积分、今日任务、可领取积分)
*
* 路由:GET /api/tcm/dailyGetGamify?diagnosis_id=:id
*
* @return \think\response\Json
*/
public function dailyGetGamify()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$result = DailyGamifyLogic::getState((int) $this->userId, $diagnosisId);
if ($result === false) {
return $this->fail(DailyGamifyLogic::getError());
}
return $this->data($result);
}
/**
* @notes 给小树浇水(领取今日已完成任务的积分,服务端校验)
*
* 路由:POST /api/tcm/dailyWaterTree
* 必填:diagnosis_id
*
* @return \think\response\Json
*/
public function dailyWaterTree()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$result = DailyGamifyLogic::waterTree((int) $this->userId, $diagnosisId);
if ($result === false) {
return $this->fail(DailyGamifyLogic::getError());
}
// 统一走 success,保证 msg 与 data 同时返回,前端可提示且刷新状态
return $this->success($result['message'] ?? '操作成功', $result, 1, 1);
}
/**
* @notes 日常记录-获取今日饮食记录
*
* 路由:GET /api/tcm/dailyTodayDietRecord?diagnosis_id=:id
*/
public function dailyTodayDietRecord()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
try {
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$record = DietRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $todayStart)
->where('record_date', '<=', $todayEnd)
->order('id', 'desc')
->find();
if (!$record) {
return $this->data(['record' => null, 'today' => date('Y-m-d')]);
}
$data = $record->toArray();
$data['record_date'] = date('Y-m-d', (int) $data['record_date']);
return $this->data(['record' => $data, 'today' => date('Y-m-d')]);
} catch (\Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 日常记录-保存今日饮食
*
* 路由:POST /api/tcm/dailySaveDietRecord
*/
public function dailySaveDietRecord()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$diagnosis = $check['diagnosis'];
$breakfast = trim((string) $this->request->post('breakfast_foods', ''));
$lunch = trim((string) $this->request->post('lunch_foods', ''));
$dinner = trim((string) $this->request->post('dinner_foods', ''));
$note = trim((string) $this->request->post('note', ''));
if ($breakfast === '' && $lunch === '' && $dinner === '') {
return $this->fail('请至少填写一餐饮食');
}
try {
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$existing = DietRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $todayStart)
->where('record_date', '<=', $todayEnd)
->order('id', 'desc')
->find();
$payload = [
'diagnosis_id' => $diagnosisId,
'patient_id' => (int) $diagnosis['patient_id'],
'record_date' => $todayStart,
'breakfast_foods' => $breakfast,
'lunch_foods' => $lunch,
'dinner_foods' => $dinner,
'note' => $note,
];
if ($existing) {
$payload['id'] = $existing['id'];
DietRecord::update($payload);
return $this->success('已更新今日饮食', ['id' => $existing['id']]);
}
$created = DietRecord::create($payload);
return $this->success('饮食记录已保存', ['id' => $created->id]);
} catch (\Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 日常记录-AI 今日饮食推荐(霍大夫升糖指数)
*
* 路由:GET /api/tcm/dailyDietAiRecommend?diagnosis_id=:id&refresh=0|1
*/
public function dailyDietAiRecommend()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$refresh = (int) $this->request->get('refresh', 0) === 1;
$result = DailyDietAiLogic::getDailyRecommend($diagnosisId, $refresh);
if (empty($result['ok'])) {
return $this->fail($result['error'] ?? '获取推荐失败');
}
return $this->data($result['data']);
}
/**
* @notes 日常记录-AI 饮食问答(能不能吃某食物)
*
* 路由:POST /api/tcm/dailyDietAiAsk body: diagnosis_id, question
*/
public function dailyDietAiAsk()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$question = trim((string) $this->request->post('question', ''));
$result = DailyDietAiLogic::askFood($diagnosisId, $question);
if (empty($result['ok'])) {
return $this->fail($result['error'] ?? '咨询失败');
}
return $this->data($result['data']);
}
/**
* @notes 日常记录-AI 今日饮食推荐(SSE 流式)
*
* 路由:GET /api/tcm/dailyDietAiRecommendStream?diagnosis_id=:id&refresh=0|1
*/
public function dailyDietAiRecommendStream()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$refresh = (int) $this->request->get('refresh', 0) === 1;
$this->runDailyDietSse(static function (callable $emit) use ($diagnosisId, $refresh): void {
DailyDietAiLogic::streamDailyRecommend($diagnosisId, $refresh, $emit);
});
}
/**
* @notes 日常记录-AI 饮食问答(SSE 流式)
*
* 路由:POST /api/tcm/dailyDietAiAskStream body: diagnosis_id, question
*/
public function dailyDietAiAskStream()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$question = trim((string) $this->request->post('question', ''));
$this->runDailyDietSse(static function (callable $emit) use ($diagnosisId, $question): void {
DailyDietAiLogic::streamAskFood($diagnosisId, $question, $emit);
});
}
/**
* @param callable(callable(string, array<string, mixed>):void):void $runner
*/
private function runDailyDietSse(callable $runner): void
{
while (ob_get_level() > 0) {
ob_end_clean();
}
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', '0');
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
header('Content-Encoding: none');
// 撑开 Nginx/FastCGI 缓冲,让后续 chunk 能立即下发
echo ':' . str_repeat(' ', 2048) . "\n\n";
if (function_exists('ob_flush')) {
@ob_flush();
}
flush();
$emit = static function (string $event, array $payload): void {
echo 'event: ' . $event . "\n";
echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
if (function_exists('ob_flush')) {
@ob_flush();
}
flush();
};
$emit('start', ['message' => '已连接,正在生成…']);
try {
$runner($emit);
} catch (\Throwable $e) {
$emit('error', ['message' => '服务异常,请稍后再试']);
}
exit;
}
/**
* @notes 日常记录-获取今日运动记录
*
* 路由:GET /api/tcm/dailyTodayExerciseRecord?diagnosis_id=:id
*/
public function dailyTodayExerciseRecord()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
try {
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$record = ExerciseRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $todayStart)
->where('record_date', '<=', $todayEnd)
->order('id', 'desc')
->find();
if (!$record) {
return $this->data(['record' => null, 'today' => date('Y-m-d')]);
}
$data = $record->toArray();
$data['record_date'] = date('Y-m-d', (int) $data['record_date']);
return $this->data(['record' => $data, 'today' => date('Y-m-d')]);
} catch (\Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 日常记录-保存今日运动
*
* 路由:POST /api/tcm/dailySaveExerciseRecord
*/
public function dailySaveExerciseRecord()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
if (!$check['ok']) {
return $this->fail($check['error']);
}
$diagnosis = $check['diagnosis'];
$exerciseType = trim((string) $this->request->post('exercise_type', ''));
$durationRaw = $this->request->post('duration', '');
$intensity = (int) $this->request->post('intensity', 2);
$note = trim((string) $this->request->post('note', ''));
$duration = null;
if ($durationRaw !== '' && $durationRaw !== null && is_numeric($durationRaw)) {
$d = (int) $durationRaw;
if ($d > 0) {
$duration = $d;
}
}
if ($exerciseType === '' && $duration === null) {
return $this->fail('请填写运动类型或时长');
}
if ($intensity < 1 || $intensity > 3) {
$intensity = 2;
}
try {
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$existing = ExerciseRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $todayStart)
->where('record_date', '<=', $todayEnd)
->order('id', 'desc')
->find();
$payload = [
'diagnosis_id' => $diagnosisId,
'patient_id' => (int) $diagnosis['patient_id'],
'record_date' => $todayStart,
'exercise_type' => $exerciseType,
'duration' => $duration,
'intensity' => $intensity,
'note' => $note,
];
if ($existing) {
$payload['id'] = $existing['id'];
ExerciseRecord::update($payload);
return $this->success('已更新今日运动', ['id' => $existing['id']]);
}
$created = ExerciseRecord::create($payload);
return $this->success('运动记录已保存', ['id' => $created->id]);
} catch (\Exception $e) {
return $this->fail($e->getMessage());
}
}
/**
* @notes 保存稳糖分 / 勋章 / 任务领奖状态
*
* 路由:POST /api/tcm/dailySaveGamify
* 参数:diagnosis_id, points, badges(数组), task_awards(对象)
*
* @return \think\response\Json
*/
public function dailySaveGamify()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$points = (int) $this->request->post('points', 0);
$badges = $this->decodeJsonPostField($this->request->post('badges', ''), 'badges/a');
$taskAwards = $this->decodeJsonPostField($this->request->post('task_awards', ''), 'task_awards/a');
$result = DailyGamifyLogic::saveState((int) $this->userId, $diagnosisId, $points, $badges, $taskAwards);
if ($result === false) {
return $this->fail(DailyGamifyLogic::getError());
}
return $this->data($result);
}
/**
* @notes 生成日常记录分享邀请码(仅当天有效)
*
* 路由:POST /api/tcm/dailyCreateShareInvite
* 必填:diagnosis_id
*
* @return \think\response\Json
*/
public function dailyCreateShareInvite()
{
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
$result = DailyShareLogic::createInvite((int) $this->userId, $diagnosisId);
if ($result === false) {
return $this->fail(DailyShareLogic::getError());
}
return $this->data($result);
}
/**
* @notes 凭邀请码查看分享战报(无需登录,含近7日血糖/血压记录)
*
* 路由:GET /api/tcm/dailySharePreview?invite_code=XXXXXXXX
*
* @return \think\response\Json
*/
public function dailySharePreview()
{
$inviteCode = (string) $this->request->get('invite_code', '');
$viewerKey = (string) $this->request->get('viewer_key', '');
$result = DailyShareLogic::previewByInviteCode($inviteCode, $viewerKey);
if ($result === false) {
return $this->fail(DailyShareLogic::getError());
}
return $this->data($result);
}
/**
* @notes 家人点赞(邀请观看页,无需登录)
*
* 路由:POST /api/tcm/dailyFamilyLike
* 必填:invite_code, viewer_key
* 可选:nickname(家人/亲友/老伴 等)
*
* @return \think\response\Json
*/
public function dailyFamilyLike()
{
$inviteCode = (string) $this->request->post('invite_code', '');
$viewerKey = (string) $this->request->post('viewer_key', '');
$nickname = (string) $this->request->post('nickname', '');
$result = DailyFamilyLikeLogic::addLike($inviteCode, $viewerKey, $nickname);
if ($result === false) {
return $this->fail(DailyFamilyLikeLogic::getError());
}
return $this->data($result);
}
/**
* @notes 患者查看今日家人点赞汇总
*
* 路由:GET /api/tcm/dailyFamilyLikeSummary?diagnosis_id=
*
* @return \think\response\Json
*/
public function dailyFamilyLikeSummary()
{
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
$result = DailyFamilyLikeLogic::summaryForPatient((int) $this->userId, $diagnosisId);
if ($result === false) {
return $this->fail(DailyFamilyLikeLogic::getError());
}
return $this->data($result);
}
/**
* @notes 根据订单号获取订单详情(供小程序支付页调用)
* @return \think\response\Json
+7 -3
View File
@@ -255,10 +255,14 @@ class UserLogic extends BaseLogic
}
// 绑定手机号
User::update([
$update = [
'id' => $params['user_id'],
'mobile' => $phoneNumber
]);
'mobile' => $phoneNumber,
];
if (isset($params['sex']) && in_array((int) $params['sex'], [1, 2], true)) {
$update['sex'] = (int) $params['sex'];
}
User::update($update);
return true;
} catch (\Exception $e) {

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