Compare commits

..
125 Commits
Author SHA1 Message Date
Your Name be15531ab3 更新 2026-06-01 18:34:50 +08:00
Your Name 0a781bda54 更新 2026-06-01 17:43:01 +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
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
Your Name 57e4892140 更新 2026-05-26 18:11:58 +08:00
Your Name 95fff1262b 更新
Merge branch 'master' into master-5-24
2026-05-26 18:09:41 +08:00
Your Name 0dd9cdcba9 更新功能 2026-05-26 18:05:48 +08:00
Your Name 5b233fb0ab 更新 2026-05-26 11:23:43 +08:00
long 3325be0622 feat(metronome): 双引擎(BgAudio 档位+InnerAudio 自定义)+音频迁移 CDN
- 档位模式用 BackgroundAudioManager+预合成循环 mp3, 锁屏可继续播放
- 自定义模式保留 InnerAudio, 支持任意 BPM(40~240)/拍号(2/3/4), 仅前台
- UI 折叠区区分两种模式, preset 默认进, 点"⚙ 自定义节奏"切换
- 音频资产全部迁移到腾讯云 COS, 删除本地 6 个 mp3/jpg (-210KB)
- server 上传白名单补 mp3/wav/m4a/aac/amr/wma 格式
2026-05-26 11:15:15 +08:00
Your Name 9af5c5be63 Merge branch 'master' into master-5-24 2026-05-26 09:43:48 +08:00
longandCursor 0e46bf8e0d feat(metronome): 升级到 BackgroundAudioManager,iOS 真后台播放
iOS 微信小程序硬限制:
  InnerAudioContext 切到后台/锁屏一定被挂起,
  即使配置 requiredBackgroundModes 也无效。
  唯一支持 iOS 真后台的音频 API 是 BackgroundAudioManager。

新增预合成档位音轨(ffmpeg 拼接 click + click-wood):
- loop_80bpm_2.mp3   慢走 6.0s 4 周期循环 (72 KB)
- loop_110bpm_2.mp3  健走 5.45s 5 周期循环 (66 KB)
- loop_130bpm_2.mp3  快走 5.54s 6 周期循环 (67 KB)
- cover.jpg          200×200 emerald 占位封面 (474B)

新 hook training/hooks/useMetronomeBg.ts:
- 封装 uni.getBackgroundAudioManager() 单例
- 必填 metadata: title/coverImgUrl/singer/epname/webUrl
- onEnded 重赋 src 实现循环 (BgAudio 无原生 loop 属性)
- onPause 同步 isPlaying,用户从锁屏控制条点暂停 UI 自动同步
- 暴露 playLoop(id)/pause/resume/stop API

metronome.vue 改造:
- 切换为 useMetronomeBg,移除右下角微调和拍号选择
  (预合成 mp3 改不了 BPM/拍号,要么砍要么扩 9 个 mp3)
- 中央圆按钮逻辑:
  · 未选档位 → 默认开"健走"
  · 选了档位且在播 → 暂停
  · 选了档位且暂停 → 恢复
- 视频 playbackRate 跟当前档位 BPM 同步(80→0.65× / 110→0.89× / 130→1.05×)
- 加底部提示"🔒 锁屏可继续播放,放兜里走也能听到"
- onHide 不 stop,onUnmounted 才 stop(避免锁屏控制条挂死)

副作用:
- 锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(老人友好)
- 切档位有 200~500ms 延迟 (用户主动操作可接受)
- 不再支持任意 BPM 微调 (3 档已覆盖大部分场景)

旧的 useMetronome.ts 暂时保留,如 BgAudio 真机验证 OK 再清理。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:39:33 +08:00
longandCursor b93313d935 docs(training): 沉淀 BgAudio 设计备忘到 README
记录未来做健身模块时上 BackgroundAudioManager 的完整方案,
避免再调研一次。包含:

- 引擎选择对照表(BGM 用 BgAudio / 短音用 InnerAudio)
- BackgroundAudioManager 7 条限制清单
- 健身模块推荐架构(双引擎共存)
- 必备资产清单(BGM mp3 + 200×200 封面)
- BgAudio API 速查 + 场景切换模式代码
- 节拍器为何不一并升级的判断依据

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:27:56 +08:00
longandCursor c1dfb01f53 fix(metronome): 切后台/小窗后节拍声继续(放兜里也能听)
两层原因都修:

1. 微信限制(声明后台音频权限):
   InnerAudioContext 默认在小程序进后台时被强制暂停。
   必须显式声明 requiredBackgroundModes,iOS 才允许后台继续发声。
   - manifest.json mp-weixin 全局加 requiredBackgroundModes:["audio"]
   - pages.json 节拍器页 style 也加,双保险
   说明:Android 大部分情况无需此声明也能后台,iOS 必需。

2. 代码层(onHide 不再主动 stop):
   原 onHide 里 if(isPlaying) stop() 会在切后台/小窗时
   立刻把节拍器自己关掉,跟需求相反。

   新策略:
   - onHide 留空 → 切后台/小窗时节拍器继续工作
   - onUnmounted 才真正 stop + 释放屏幕常亮 → 用户主动离开
     节拍器页(navigateBack/重启)时清理资源

实际场景:健走时把手机塞口袋,锁屏或切到微信聊天,
节拍声依然继续响,无需把手机一直拿着看。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:19:19 +08:00
longandCursor 3f999b312d tweak(metronome): 三档位拍数统一改为 2 拍
慢走/健走/快走 三档 accent 从 4 → 2:
- 走路场景天然是左-右双脚交替的 2 拍循环
- 重音落在每一步的"主脚"(默认左脚),节奏感更贴合步态
- 4 拍听感像跑步进行曲,2 拍才是健走/慢跑实际节奏

需要 3/4 拍号的可在右下角微调里手动切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:05:31 +08:00
longandCursor 50abe5dece refactor: 训练模块整体迁入分包,减小主包体积
主包超出 2MB 上限无法上传发布版,把训练相关全部移到独立分包。

目录调整:
- pages/training/* → training/pages/*
- hooks/use{Metronome,VoiceCoach,TrainingSession,TrainingBgm}.ts
  → training/hooks/*
- static/training/* → training/static/*
- 主包 hooks/ 目录已清空,所有 use* 都被分包独占

引用路径更新:
- 分包内 hook import 用相对路径 ../hooks/xxx,不依赖 @/ alias
- 资源路径 /static/training/audio/click.mp3
  → /training/static/audio/click.mp3
- useMetronome 默认 clickSrc / useVoiceCoach VOICE_BASE
  / useTrainingBgm BGM_BASE 全部对齐新路径

pages.json:
- 主包 pages 移除 training/index 和 training/metronome
- subPackages 新增 root="training",含 pages/index 和 pages/metronome
- 跟现有 TUIKit / doctor 分包风格一致

调用方:
- components/dev-training-entry navigateTo URL 更新成
  /training/pages/metronome (此组件留在主包,主包跳分包是合法操作)

文档/工具同步:
- training/static/README.md 路径示例改为 training/static/...
- scripts/generate-voice.mjs VOICE_DIR 与提示输出对齐新路径

Git mv 检测到的全是 rename(R),历史完整保留。
分包总体 80K(pages 44K + hooks 20K + static 16K),主包瘦身明显。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 09:00:01 +08:00
Your Name 58c224a081 更新 2026-05-25 18:32:17 +08:00
long f718df833e update 2026-05-25 18:28:55 +08:00
longandCursor f068acf390 style(entry): 主按钮换心跳波形 icon,暂时隐藏练一练菜单
主按钮:
- "练" 单字改为 EKG 心跳波形 SVG icon
- 内联 data-uri SVG, 60rpx, 白色 stroke 2.6, 圆角线
- 加 1.6s 心跳脉动动画,模拟两次收缩(20%/40%),
  跟外圈呼吸光环错频,层次感更强
- 健康主题更直观,跟运动/节拍场景契合

菜单:
- "练一练" 项暂时注释隐藏(后续恢复直接取消注释)
- 当前展开只剩"节拍器"一个入口

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 18:28:06 +08:00
longandCursor 769d912eef style(entry): 训练浮动入口美化,移除 DEV 标签
主按钮:
- 移除右上角红色 DEV 标签
- 单字"练" 48rpx 粗字号居中,不再 emoji+文字双层
- emerald 色由 #34d399 → #047857 渐变,跟节拍器主色统一
- 加双层立体阴影 + inset 高光,看起来像凸起的玻璃按钮
- 加 2 圈错相位呼吸光环(2.4s 周期),暗示可点击
- 关闭态用 CSS 几何 X 图标,旋转 135° 动画过渡

展开菜单:
- 圆角药丸卡片,带 backdrop-blur 玻璃质感,层级阴影
- "练一练"圆形 icon emerald 渐变 + 💪 emoji
- "节拍器"图标改成 3 根高低柱 CSS 模拟节拍/均衡器,
  琥珀色渐变跟节拍器页面重音色呼应
- 右侧加细 › 箭头暗示可跳转
- 入场动画从 scale(0.92) 弹性放大,更顺滑

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 18:24:35 +08:00
longandCursor 5aa0cda252 fix(metronome): 视频区域消除黑边 + PIP 遮罩融入画面
黑边原因:容器 280×240 跟 video 内容 1:1 不匹配,
微信 native video 默认 #000 底色从两侧露出来。

修复:
- walker-box 改成 260×260 正方形,跟视频比例一致,
  contain 模式下视频撑满整个容器,左右没缝隙
- 容器和 video 的 background 都设成 #e8ecf2
  (匹配视频右上角浅灰白渐变),万一还有 1rpx 缝也不刺眼
- 容器加微妙的 box-shadow,跟下方档位卡片视觉对齐

PIP/小窗按钮遮罩融入视频画面:
- cover-view 颜色从页面色 #f8fafc 改成 #dde2eb
  (匹配视频右上角的浅灰渐变中间色)
- 盖在 video 上就像视频画面的一部分,看不出是色块
- 尺寸缩到 80×56 刚好覆盖 PIP 按钮,不多遮挡画面
- 左下加 18rpx 圆角让边缘柔和过渡

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 18:17:59 +08:00
longandCursor 552da3aec2 feat(metronome): 节拍音改用真实木鱼录音
来源:Freesound 用户 the_very_Real_Horst 的"Fischtrommel_Muyu.mp3"
(ID 205999, CC BY 4.0,真实中国木鱼/Mokugyo 录音)

从 26 秒原录音中提取 1.665s 处第一个干净敲击,
ffmpeg 处理:
- 普通拍 click.mp3:原音木鱼,截 0.15s,标准化峰值 -0.3dB
- 重音拍 click-wood.mp3:asetrate 升 +3 半音让音色更清脆,
  截 0.18s,标准化峰值 -0.4dB
- alimiter 限幅 0.97 防爆音,exp 衰减保留自然敲击尾巴

听感:
- 取代之前 ffmpeg 纯正弦合成的"叮"声
- 真乐器录音,木质共鸣空腔感清晰
- 重音通过升音区分,跟普通拍音色统一不违和
- mean 响度比合成版提升约 10dB

旧合成版备份在 audio/_backup/,不喜欢可一键恢复

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 18:12:35 +08:00
longandCursor ed5285b58a fix(metronome): 视频改矩形完整显示,cover-view 挡住 PIP 按钮
之前圆形蒙版 + scale(1.4) 把小人手臂和头部裁掉一部分,
改成矩形容器:
- walker-box 280×240 微圆角矩形,object-fit:contain
  小人四肢完整露出,不再被切
- 容器 background 跟页面色一致,video 自带灰白边自然融入
- 移除 transform:scale 缩放(原本只为推 PIP 按钮出圆外)

PIP/小窗按钮处理改为 cover-view:
- cover-view 是小程序唯一能覆盖在 native video 上的元素
- 右上角放 86×64 跟背景同色的色块,挡住原生按钮
- #ifdef MP-WEIXIN 条件编译,只在小程序端启用

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 17:47:19 +08:00
longandCursor 0d35d1b6c3 fix(metronome): 顶部色协调 + 视频边缘柔化 + 节拍音加大
NavBar 颜色协调:
- 节拍器页 navigationBarBackgroundColor 从 #0f172a 暗色
  改为 #f8fafc,跟页面背景纯色一致,顶部不再撕裂
- 文字色改 black,补 backgroundColor 防止下拉露出底色

视频边缘柔化(去掉硬切感):
- 页面背景从渐变改为纯色 #f8fafc,让 box-shadow 颜色稳定
- walker-clip 加 box-shadow 0 0 30rpx 12rpx #f8fafc
  圆形外圈"消融"到背景里,不再有硬边框感
- video transform: scale(1.1) → 1.4
  让 video 自带的灰白渐变边和右上角原生 PIP/小窗按钮
  全部被推到圆外,被 overflow:hidden 切掉

节拍音重做(响度+音色):
- 时长从 0.13s 缩到 0.085s,去掉衰减尾巴更干脆
- 加 5ms white-noise burst 模拟敲击瞬间的"tk"音头
- volume 拉到 6 倍 + alimiter 限 0.98 防爆音
- 实测峰值从 -13.5dB 推到 0dB(满量程),
  mean 从 -26dB 提到 -19dB,听感响度约翻倍

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 17:28:25 +08:00
longandCursor 27c2ada4ec feat(metronome): 浅色清爽改版 + 三档位 + 阳光节拍音
整页配色从暗色系换成浅色清爽:
- 背景 #f8fafc → #eef2f7 渐变
- 卡片纯白 + 柔和阴影
- 主圆 emerald 翡翠绿,重音切琥珀色

视频区域:
- 移除卡片外框/光晕/阴影,保留圆形蒙版
  (圆形蒙版仍负责裁掉 video 灰白边 + 隐藏微信原生右上角小窗按钮)
- video 透明背景,跟白底融合

新增 3 档位推荐(主操作):
- 慢走 80 BPM · 健走 110 BPM · 快走 130 BPM
- 横排白卡,选中变浅绿 + emerald 描边
- 一键同步 BPM 和拍号,免去手动调

右下角微调(次要操作):
- absolute 定位胶囊,7 成透明
- ± BPM 微调 + 2/3/4 拍号
- 不抢主视觉,需要精确时才点

替换节拍音色(ffmpeg 合成):
- 普通拍 C6 1046Hz + C7 泛音,清脆"叮"
- 重音 G6 1568Hz + D7 + G7,更明亮"叮咚"
- 加 5ms attack + exp decay + 轻微回声,模拟钢片琴
- 旧的阴沉鼓点声备份在 audio/_backup/

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 17:14:45 +08:00
longandCursor 6be5b4c45d feat(metronome): walker 卡片改左右布局 + 圆形蒙版小人
老布局问题:视频被困在小白卡里、灰白渐变底跟暗卡片不搭、
左右大量空白、右上角小窗按钮没办法靠属性彻底隐藏。

新布局:
- 圆形蒙版裁切 video,灰白底自动收成柔和光晕,
  原生右上角小窗/投屏按钮被切到圆外,看不见
- video 加 transform:scale(1.08) 让小人居中且裁掉边缘灰边
- 圆外加随节拍脉动的彩色 glow,颜色跟 badge 联动
  (健走绿/快走黄/慢跑橙/快跑红)
- 视频右侧填充节奏数据三行:
  · 每分钟 X 步
  · 每步耗时 X ms
  · 动画速度 X ×
  把空白填上有意义的信息层级

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 17:02:47 +08:00
longandCursor fbf3d47b2a fix(metronome): 跑步视频改用腾讯云 COS CDN 地址
微信开发者工具加载代码包内 mp4 极不稳定,改用 CDN 直接 URL:
- 抽取 RUNNER_VIDEO_URL 常量,后续替换源只改一处
- 注释里标记需把 cos.ap-guangzhou.myqcloud.com 加到
  下载合法域名白名单(正式发布前)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:41:28 +08:00
longandCursor 82c4d7e0a2 fix(metronome): 视频加载排错与初始化兼容性
针对开发者工具/老基础库下视频卡加载圈的问题:

- 去掉 video 标签上的 :playback-rate 属性绑定
  (部分基础库初始化时设非 1.0 速率会导致首帧解码失败/卡死)
- 改在 @loadedmetadata 元数据加载完后再调用 videoCtx.playbackRate API
  这样视频先以 1.0 原速播放,确认 ready 后再调速,兼容性更好
- 新增 @error 事件监听,失败时 console + toast 提示
  便于诊断真机/工具的差异表现

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:31:45 +08:00
longandCursor 867a6a97cf feat(metronome): walker 区改用 MP4 视频,playbackRate 跟 BPM 同步
将之前的 PNG runner + CSS 律动方案,升级为真正的卡通 walk cycle 视频:

- 删除 runner.png(静态图无法表现关节运动)
- 新增 runner.mp4(LottieFiles 卡通跑步动画,150x150, 60fps, 27KB)
- walker 区从 image + CSS 抖动 改为 video + playback-rate 调速
- 视频原速基准 124 BPM(0.97s 一个 walk cycle = 2 步)
- playbackRate = clamp(bpm/124, 0.5, 2.0),贴合微信小程序限制
- 视频与节拍器同步策略:
  · watch isPlaying → 自动 play/pause
  · watch playbackRate → 调用 videoCtx.playbackRate API 兜底
  · onMounted 默认暂停,等用户点开始才播
- 视频白底跟暗色卡片冲突,改用"嵌入式高亮卡片"设计:
  · runner-card 白底 + 32rpx 圆角 + 阴影,像专业健康 APP
  · video object-fit contain 保证不被裁切
- 移除冗余的 .ground 地面线样式

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 16:15:46 +08:00
longandCursor a3ee72b34d feat(metronome): walker 区接入卡通跑步小人,跟 BPM 同步律动
- 替换原来两只脚印为一张跑步小人 PNG(static/training/runner.png)
- 静态 PNG + CSS 律动:
  · runner-bob keyframes: 1 拍内做"下蹲→起跳→反向倾斜→落地"
    四个阶段,模拟跑步身体起伏
  · 上下抖动 ±14rpx, 左右轻摆 ±1.5°,scale Y 微妙压缩
  · drop-shadow 让人物有立体感
- 阴影同步:runner-shadow-pulse 跟律动反向变化
  (跳起时阴影变小变淡 0.2,落地时变大变浓 0.5)
- 动画 duration 绑定 --step-duration = 60000/bpm
  · BPM 60  → 1000ms/次,慢悠悠
  · BPM 120 → 500ms/次,正常跑
  · BPM 180 → 333ms/次,紧张快跑

注意: 用户提供的文件实际是静态 PNG 不是 SVG 动图,
所以是"画面整体律动"而非真正的 walk cycle 关节运动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 15:45:43 +08:00
longandCursor a08031b901 style(metronome): 禁止页面上下滚动
- pages.json 给 pages/training/metronome 加 disableScroll: true
  (微信小程序级别禁滚动,主要避免下拉刷新和顶部回弹)
- .page CSS 改 min-height → height: 100vh + overflow: hidden
  (双保险,防止 iOS 橡皮筋效果与内容超出滚动)
- 内容元素 gap 28→24rpx,微调避免内容被裁

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 14:15:36 +08:00
longandCursor 665481a6d8 style(footprint): 圆润脚跟,告别尖锐 V 形
之前两段曲线都汇聚到 (50,138) 一点,且控制点位置偏下,
形成尖锐 V 形脚跟。修复:

- 脚跟底中心 (50,138) → (50,140),稍微下移
- 内侧弧控制点 (38,130) → (30,122)(40,140),从靠肩部
  渐进到水平方向,切线在底部呈水平,不再向下尖收
- 外侧弧对称处理 (60,140)(70,122)
- 进入和离开脚跟底心时切线都水平,视觉上变成圆润 U 形
- 整体高度 138 → 140,viewBox 100x150 不变

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 13:02:39 +08:00
longandCursor 30b344b776 style(metronome): 脚印重画为葫芦型,告别椭圆感
之前的 SVG path 内外侧太对称,看起来还是椭圆。重新设计:

- viewBox 100x140 → 100x150 (拉长比例 1:1.5,更"瘦长")
- 主体 path 内侧凹陷 + 外侧饱满,形成葫芦轮廓:
  · 内侧足弓 x 收到 22 (凹陷)
  · 外侧饱满 x 推到 78 (凸出)
  · 前足比脚跟略宽,模拟真实脚型
- 5 个脚趾:
  · 拇趾 rotate(-18°) 内侧明显倾斜
  · 小趾 rotate(+22°) 外侧外展
  · cy 沿弧线 22→11→9→13→20,形成自然弧形
  · rx 从 6.5 递减到 3.5,大小过渡更自然
- 容器尺寸从 100x140 → 96x144,匹配 SVG 新比例
- walker 高度 200→220rpx 以容纳抬脚动画上限

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 12:49:36 +08:00
longandCursor e104249707 style(metronome): 脚印改用 SVG 矢量,单笔成型告别卡通感
div 拼接(椭圆+5 个圆)无论怎么调都摆脱不了"青蛙脚"既视感,
根本原因是 div 画不出曲线轮廓。改用 SVG path 单笔成型:

- 新增 static/training/footprint.svg(775B)
  · path 一笔走完:前足→足弓凹陷→脚跟,模拟真实脚印拓印
  · 5 个脚趾用 ellipse + rotate 转角,大小递减(7.2→3.8rpx)
  · 内侧(拇趾侧)凹陷、外侧饱满,符合解剖学
  · 单色 #e2e8f0 fill-opacity 0.92,接近湿沙脚印质感

- metronome.vue:
  · 模板:删 heel + 5×toe,改用 image src=footprint.svg
  · CSS:删除 70+ 行 heel/toe 位置定位,只保留 footprint 容器
  · 左脚保持 scaleX(-1) 镜像,动画 keyframe 不变

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 12:45:17 +08:00
longandCursor 9b6d9ce007 refactor(metronome): 整体视觉重设计 - 现代极简风
脚印拟真化:
- 颜色 鲜绿 → 浅白(rgba 241,245,249,0.92),降低饱和不再卡通
- 形状更紧凑:脚趾紧贴脚掌、大小递减弧形排布(22→18→16→14→12rpx)
- 不规则 border-radius 模拟真实脚印轮廓
- 移除多余渐变和厚重 box-shadow

布局重组(卡片化):
- 走路区域 + 配速 badge + 状态提示  → walker-card 统一卡片
- BPM 调节 + 拍号选择            → control-card 统一卡片
- 卡片背景 rgba(255,255,255,0.035) + 1rpx 描边,有 backdrop-blur
- 移除独立 hint 区,合并到 walker-card 顶部

控件精修:
- BPM 步进按钮 高度 120→96rpx,字号 44→38rpx,圆角更小
- 拍号选择器改为 iOS 风药丸式 segmented control,放在右侧
- 主圆 460→420rpx,BPM 字号 200→180rpx,腾出空间给下方
- "速度"标签 → "BPM"(更专业)

配速 badge 智能变色:
- <100 BPM  绿(健走)
- 100-129  黄(快走)
- 130-169  橙(慢跑)
- ≥170     红(快跑)

设计 token 抽取:
- 在 SCSS 顶部定义 $bg-grad / $card-bg / $text-* / $brand 等变量
- 圆角统一:卡片 28rpx, 按钮 20rpx, 药丸 999rpx
- 间距统一:卡片间距 28rpx, 控件间隔 14rpx

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 12:18:29 +08:00
longandCursor 69da8066a6 feat(metronome): 节拍器下方加脚步行走动画
- 节拍器圆下方新增"脚印踏步"动画区域
  · 纯 CSS+SVG-like(div 拼接),不依赖 canvas/Lottie/视频
  · 左右脚交替踏步,起脚时落地阴影变小变淡,落地时变大变浓
  · 1 拍 1 步,左右各占半周期(animation-delay 错相 0.5)
- 跟节拍器同步:CSS 变量 --step-duration = 60000/bpm,BPM 改了
  步频立刻跟着变;暂停时 animation-play-state 自动停
- 智能配速提示:
  · <70 BPM   慢走 · 适合热身
  · 70-99    健走 · 日常配速
  · 100-129  快走 · 心率提升
  · 130-169  慢跑 · 进入有氧
  · ≥170     快跑 · 高强度
- 重构 ringStyle/coreStyle 为统一 rootStyle,挂在根节点用
  CSS 变量级联(--beat-duration / --step-duration)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 12:10:44 +08:00
longandCursor edc9993403 fix(metronome): 修复 iOS 真机静音键模式下无声音
iOS 微信小程序的 InnerAudioContext 默认会受系统侧边静音键影响,
单实例 obeyMuteSwitch = false 在 iOS 上不可靠,必须调用全局
API setInnerAudioOption 才能正常发声。

- App.vue onLaunch 调用 uni.setInnerAudioOption({
    obeyMuteSwitch: false, mixWithOther: true })
- useMetronome.preload() 进页面时再调一次保险
- AudioPool.play() 改用 stop() + play() 替代 seek(0) + play(),
  iOS 上 seek(0) 不一定能复位播放进度
- 预热改用 volume 0.01 极小音量替代 volume 0,iOS 上音量为 0
  可能不会真正完成音频解码

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:48:16 +08:00
longandCursor 8f14f3190e fix(metronome): 修复第二拍延迟两倍间隔的 bug
drift correction 逻辑写反了:
  drift = now - nextTickAt   (此时为负数,下一拍还在未来)
  nextDelay = intervalMs - drift    = 2 × intervalMs

正确做法直接计算"目标时刻距现在还有多久":
  nextDelay = nextTickAt - now  

现象:点击开始,第一拍立即响,但第二拍要等两倍拍间隔才响。
现在第一拍响完就立刻按 BPM 节奏进入第二拍。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:44:06 +08:00
long 71a3c63182 Update .gitignore 2026-05-25 11:33:01 +08:00
longandCursor d10678763f revert(uniapp): 移除训练模块,代码已迁至 TUICallKit-Vue3
之前 6 个 commit 误把节拍器/练一练训练模块加在 uniapp 项目,
实际目标项目是 TUICallKit-Vue3(甄养堂主壳)。本提交清理 uniapp
项目所有相关改动:

- 删除 hooks/useMetronome.ts、useTrainingSession.ts、
  useVoiceCoach.ts、useTrainingBgm.ts
- 删除 pages/training/* 与 components/dev-training-entry/
- 删除 static/training/* 与 scripts/generate-voice.mjs
- 还原 pages.json、pages/index/index.vue、pages/user/user.vue

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:29:03 +08:00
longandCursor 2beace89f4 feat(TUICallKit-Vue3): 迁入节拍器与练一练训练模块
之前误将训练模块加在 uniapp 项目,实际目标项目是 TUICallKit-Vue3
(甄养堂主小程序壳),整体迁入:

- hooks/useMetronome.ts  节拍器(音频实例池+预热,无冷启动延迟)
- hooks/useTrainingSession.ts  训练 Session 状态机
- hooks/useVoiceCoach.ts  语音教练队列
- hooks/useTrainingBgm.ts  训练/休息 BGM 切换+渐变
- pages/training/index.vue  练一练主页(器械跟练)
- pages/training/metronome.vue  独立节拍器(老人友好版,
  中央圆即开始/暂停按钮,纯 CSS 几何 icon,大字大按钮)
- pages/training/components/exercise-anim.vue  CSS 动作动画
- pages/training/exercises.ts  5 个器械动作预设
- static/training/audio/*.mp3  3 种 click 音色(MIT 协议)
- scripts/generate-voice.mjs  MiMo TTS 批量生成语音脚本
- pages.json 注册 pages/training/index 与 pages/training/metronome
- 「我的」页加 DevTrainingEntry 悬浮入口,生产环境自动隐藏

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:28:55 +08:00
longandCursor e5d0d7db93 style(training/metronome): 优化中央圆 UI,移除顶部拍点指示
- 移除顶部 .beat-bar 4 个圆点指示器,界面更简洁
- 中央圆按钮重新设计:
  · "速度"标签下方居中放置纯 CSS 几何绘制的大 ▶/⏸ 图标
    (icon-play 70rpx 三角形 / icon-pause 22rpx×84rpx 双竖条),
    跨机型一致、不再依赖 emoji 字符
  · 移除"开始/暂停"文字 + 药丸样式背景,保留 icon 即可识别
  · 圆心使用更深的渐变和内/外阴影,质感更立体
  · 增加第二层 ring 错相 0.4 周期播放,呼吸感更连续
- 状态提示文案调整:"点击" → "轻点"
- 顶部 padding 收紧 60rpx → 20rpx,圆体上下居中更平衡

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:18:30 +08:00
longandCursor 35a42078ce feat(training/metronome): 修复冷启动延迟 + 老人友好 UI
- useMetronome 改用音频实例池(默认 4 个 InnerAudioContext 轮流播放),
  并在创建时静音预热,解决重新开始第一拍延迟 100-300ms 的问题;
  同时也避免快 BPM 时上一拍未播完导致的丢音
- 暴露 preload(),节拍器页面 onShow 时主动预加载
- 移除 useMetronome 的 tapTempo 接口
- 重写节拍器页面:
  · 中央大圆即"开始/暂停"按钮(老人一眼就懂的交互)
  · 移除 Tap Tempo、滑块、速度术语
  · 仅保留 -10/-1/+1/+10 大步进按钮调速
  · 拍号简化为 2/3/4 拍三选项
  · 大字号 BPM(200rpx) + 高对比度配色

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 11:02:15 +08:00
longandCursor e8e28d19fb chore(uniapp): 内置 3 种节拍器 click 音色(MIT 协议)
- 来源:johnnysn/chrono-bump 开源节拍器项目(MIT License)
- click.mp3       (3.4KB) 默认标准节拍音
- click-soft.mp3  (4.2KB) 柔和音色备选
- click-wood.mp3  (4.6KB) 木鱼质感,节拍器页用作重音 accent
- 节拍器页接入 accent 音色:第 1 拍用 click-wood,后续用 click,听感更专业
- README 注明来源、协议、替换说明

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:53:31 +08:00
longandCursor 163e40c73f feat(uniapp): 节拍器独立工具页面
- 新增 /pages/training/metronome 节拍器页面:
  · 中心大脉冲圆 + 重音变色(绿→金)
  · 拍号选择 1/4 ~ 6/8,第 1 拍重音视觉指示
  · BPM 滑块 (40-240) + 一键 ±1/±5 步进
  · Tap Tempo(连续 2-5 次点击自动测速)
  · 速度术语提示(Largo / Andante / Allegro 等)
  · 屏幕常亮、切后台自动暂停
- useMetronome 新增 setAccentEvery / tapTempo 方法,支持 accentSrc 重音音色
- 训练页右上角加节拍器入口
- DEV 悬浮按钮升级为可展开菜单(练一练 + 节拍器)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:48:06 +08:00
longandCursor d46cfff079 feat(uniapp): 加开发用「练一练」悬浮入口
- 新组件 dev-training-entry:右下角圆形悬浮按钮,带 DEV 标签
- 仅在 isDevMode() 为真时显示,上线打包自动隐藏
- 挂载到首页和个人中心,方便快速跳转 /pages/training/index 测试

后续走装修系统给 user 页配「练一练」服务图标后即可移除此组件

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:37:23 +08:00
longandCursor 275a7a550d feat(uniapp): 新增「练一练」器械跟练模块 MVP
- 节拍器 hook (useMetronome):setInterval + 漂移自校正,60-200 BPM 误差 <30ms
- 训练状态机 hook (useTrainingSession):组数/次数/休息阶段编排
- 语音引导 hook (useVoiceCoach):队列播放,关联节拍回调报数
- BGM hook (useTrainingBgm):训练/休息双音轨手写淡入淡出
- 5 个动作预设:哑铃弯举/推举/侧平举、握力环、健身环卷腹
- CSS 动画组件 exercise-anim:动画时长由 BPM 通过 CSS 变量驱动
- TTS 生成脚本 generate-voice.mjs:调小米 MiMo TTS 自动出 39 段语音
- 静态资源目录占位与素材准备说明

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:34:45 +08:00
Your Name 5430fe0417 更新 2026-05-22 18:00:22 +08:00
long f314e2e271 Merge branch 'new-miniapp' 2026-05-22 17:55:54 +08:00
Your Name a4fb55d164 更新 2026-05-22 17:13:24 +08:00
Your Name 295c8f0623 Merge branch 'master' into master-5-22 2026-05-22 17:06:55 +08:00
long 4b749a1ee0 Merge branch 'cursor/self-input-stats' 2026-05-22 17:02:27 +08:00
longandCursor 679e61057b fix(stats/self_input): 唯一索引含 delete_time,软删后可重新录入
去掉「同日+同渠道」全局唯一,回到按录入人判重;唯一索引补 delete_time 避免软删占键导致 1062;错误提示带冲突记录 ID,迁移脚本兼容多次执行。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:01:58 +08:00
Your Name 7f7b8f0a64 更新 2026-05-22 16:57:34 +08:00
long 7e94c5dfd7 Merge branch 'cursor/self-input-stats' 2026-05-22 16:45:37 +08:00
long 003810114b debug 2026-05-22 16:45:28 +08:00
Your Name 37c5849103 更新 2026-05-22 14:20:30 +08:00
long 55521b8e11 Merge branch 'cursor/self-input-stats' 2026-05-22 12:07:36 +08:00
longandCursor e4f181e4fe fix(stats/self_input): 渠道字典、财务脱敏与同日同渠道全局唯一
自媒体来源改推广渠道字典;非白名单隐藏账户消耗/ROI;部门展示完整路径;业绩与消耗按日期+渠道全局去重。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:02:12 +08:00
longandCursor bba989c0af fix(stats/self_input): 修复保存不入库/路由 404,补全部门与自媒体来源下拉
- 模型去掉 defaultSoftDelete=0,避免 ThinkPHP 软删过滤导致新增记录"隐形"
- 菜单 paths 改相对路径并新增「自录数据统计」目录,迁移已有菜单
- Lists/Overview 补部门关联、支持按部门筛选
- 数据权限改由 self_input_stats_view_all_roles 白名单 + DataScope 控制,
  编辑/删除完全交给按钮权限校验(移除"仅本人可改"硬限制)
- 新增 /stats.self_input/mediaSourceOptions 接口,统计页/账户消耗页/录入弹窗
  共用 useMediaSourceOptions + MediaSourceSelect,去重动态加载

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 11:47:15 +08:00
Your Name d5e0ba04b7 更新 2026-05-22 11:19:18 +08:00
Your Name 22ec53c070 更新 2026-05-22 11:15:59 +08:00
longandCursor 183b6a1acf feat(stats): 新增自录转化统计模块
支持后台用户手动录入个人业绩与账户消耗,独立计算转化指标,接入 DataScope 数据权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:17:16 +08:00
Your Name 49c8c95966 更新 2026-05-21 14:41:43 +08:00
Your Name c7c264497e 修复bug 2026-05-21 14:38:31 +08:00
long 436f69c272 fix: support both exclusive and public resources for logged in users, include flags in quick returns 2026-05-21 14:36:02 +08:00
long dc72a8ccaf fix: getResourceList never returns -1, uses flags for disabled/expired tokens to keep guest mode working 2026-05-21 10:25:15 +08:00
long 499c969233 fix: distinguish disabled users from invalid tokens - disabled users see public resources as guest, only truly invalid tokens trigger re-login 2026-05-21 10:13:33 +08:00
long 3b44300216 fix: return code -1 when disabled user has token in getResourceList, triggers frontend logout 2026-05-21 10:08:15 +08:00
long 8bb7b5d30a feat: add inline switch toggle for account status in user list 2026-05-21 10:01:58 +08:00
long 4c3becf33e feat: use project standard search bar pattern for asset pages, add title/phone/date filters 2026-05-21 09:54:59 +08:00
long a42fc6453a feat: add resource edit functionality and time range filter in admin panel 2026-05-21 09:49:30 +08:00
long dad06304f1 feat: make user_ids optional in admin resource form, unlinked resources become public 2026-05-21 09:47:44 +08:00
long 03b80310f6 feat: add public resource support, usage filtering, and recordDownload API 2026-05-20 18:24:04 +08:00
long 8d015fa962 fix: dual-write token (DB+cache) and dual-read with fallback, works with or without SQL migration 2026-05-20 17:54:21 +08:00
long f639ef2fbb feat: add time dimension filter (today/3d/7d/30d) to resource list 2026-05-20 17:38:28 +08:00
long ea8e6d7c1b fix: switch asset token from file cache to database for reliability + always redirect on token invalid 2026-05-20 17:22:12 +08:00
Your Name ba1c067dce Merge branch 'master' into bug-5-20 2026-05-20 17:15:51 +08:00
Your Name 5e96fda88f 更新 2026-05-20 17:14:27 +08:00
long 076cd0fcf9 fix: add changePassword to notNeedLogin for asset token auth 2026-05-20 17:13:26 +08:00
long 44e97861d2 fix: use OSS direct upload for video and audio in resource admin 2026-05-20 16:59:19 +08:00
Your Name 93e6d02ce4 更新 2026-05-20 16:53:19 +08:00
long 60cb2b80cd feat: add dedicated change password api and fix token expire code 2026-05-20 16:53:08 +08:00
long 61d14d1b45 fix: Pivot model exception 2026-05-20 16:43:57 +08:00
long a83c583a28 feat: use material-picker for resource cos direct upload 2026-05-20 16:39:22 +08:00
long 485ba7a380 style: optimize ui for login and index pages with modern ios tech style 2026-05-20 16:06:46 +08:00
Your Name 3e4039efb0 更新 2026-05-20 15:40:01 +08:00
496 changed files with 55510 additions and 3085 deletions
+1
View File
@@ -33,3 +33,4 @@ bin-release/
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
+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
+13 -2
View File
@@ -15,11 +15,22 @@ const globalData = {
let avatarUrl = ref(""); // 声明为响应式变量
uni.CallManager = new CallManager();
onLaunch(() => {
// iOS 静音键模式下 InnerAudioContext 默认不发声,
// 必须在 onLaunch 全局调用此 API,单实例 obeyMuteSwitch 在 iOS 不可靠
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true,
})
} catch (e) {
console.warn('setInnerAudioOption failed:', e)
}
// #endif
uni.login({
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,452 @@
<template>
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
<!-- 展开后的菜单卡片(从下往上动画) -->
<view v-if="expanded" class="menu-list" @click.stop>
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
<view class="menu-icon menu-icon--dumbbell">
<view class="dumbbell-shape">
<view class="dumbbell-plate dumbbell-plate-left" />
<view class="dumbbell-bar" />
<view class="dumbbell-plate dumbbell-plate-right" />
</view>
</view>
<view class="menu-info">
<text class="menu-title">哑铃</text>
<text class="menu-sub">力量塑形</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/foot-pedal')">
<view class="menu-icon menu-icon--pedal">
<view class="foot-pedal-shape">
<view class="pedal-bar pedal-bar-left" />
<view class="pedal-bar pedal-bar-right" />
</view>
</view>
<view class="menu-info">
<text class="menu-title">脚蹬器</text>
<text class="menu-sub">下肢有氧</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/pilates-ring')">
<view class="menu-icon menu-icon--pilates">
<view class="pilates-ring-shape" />
</view>
<view class="menu-info">
<text class="menu-title">瑜伽环</text>
<text class="menu-sub">核心塑形</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/grip-ring')">
<view class="menu-icon menu-icon--grip">
<view class="grip-ring-shape" />
</view>
<view class="menu-info">
<text class="menu-title">握力环</text>
<text class="menu-sub">握力训练</text>
</view>
<view class="menu-arrow"></view>
</view>
<view class="menu-item" @click="goto('/training/pages/metronome')">
<view class="menu-icon menu-icon--metro">
<view class="metro-bar metro-bar-1" />
<view class="metro-bar metro-bar-2" />
<view class="metro-bar metro-bar-3" />
</view>
<view class="menu-info">
<text class="menu-title">耗糖节拍器</text>
<text class="menu-sub">健走配速</text>
</view>
<view class="menu-arrow"></view>
</view>
</view>
<!-- 主浮动按钮(单字"练" + 呼吸光环) -->
<view class="fab-container">
<view v-if="!expanded" class="fab-pulse" />
<view v-if="!expanded" class="fab-pulse fab-pulse-2" />
<view class="entry-fab" :class="{ expanded }" @click="toggle">
<view v-if="expanded" class="icon-close">
<view class="cross-bar bar-1" />
<view class="cross-bar bar-2" />
</view>
<!-- 心跳波形 icon: 健康主题,SVG 折线 -->
<view v-else class="fab-icon" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
bottom?: number
}>(),
{ bottom: 200 },
)
const isDevMode = (): boolean => {
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
try {
return process.env.NODE_ENV !== 'production'
} catch (_) {
return false
}
}
const visible = ref(isDevMode())
const expanded = ref(false)
const toggle = () => {
expanded.value = !expanded.value
}
const goto = (url: string) => {
expanded.value = false
uni.navigateTo({ url })
}
</script>
<style lang="scss" scoped>
$brand: #10b981;
$brand-deep: #047857;
$brand-light: #34d399;
.dev-entry-wrap {
position: fixed;
right: 24rpx;
z-index: 999;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 18rpx;
}
/* ============================================================
* 主浮动按钮
* ============================================================ */
.fab-container {
position: relative;
width: 108rpx;
height: 108rpx;
}
.entry-fab {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(140deg, $brand-light 0%, $brand-deep 100%);
box-shadow:
0 8rpx 20rpx rgba(16, 185, 129, 0.42),
0 2rpx 6rpx rgba(15, 23, 42, 0.12),
inset 0 -8rpx 16rpx rgba(0, 0, 0, 0.12),
inset 0 8rpx 16rpx rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s;
&:active {
transform: scale(0.92);
}
&.expanded {
background: linear-gradient(140deg, #94a3b8 0%, #475569 100%);
transform: rotate(135deg);
box-shadow:
0 6rpx 16rpx rgba(15, 23, 42, 0.2),
inset 0 -6rpx 14rpx rgba(0, 0, 0, 0.12),
inset 0 6rpx 14rpx rgba(255, 255, 255, 0.12);
}
.fab-icon {
width: 60rpx;
height: 60rpx;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.22));
animation: fab-icon-beat 1.6s ease-in-out infinite;
}
}
@keyframes fab-icon-beat {
0%, 60%, 100% {
transform: scale(1);
}
20% {
transform: scale(1.12);
}
40% {
transform: scale(1);
}
}
/* 关闭态 X 图标 (CSS 几何) */
.icon-close {
position: relative;
width: 36rpx;
height: 36rpx;
.cross-bar {
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 5rpx;
background: #fff;
border-radius: 3rpx;
transform-origin: center;
}
.bar-1 {
transform: translateY(-50%) rotate(45deg);
}
.bar-2 {
transform: translateY(-50%) rotate(-45deg);
}
}
/* 呼吸光环(2 圈错相位扩散) */
.fab-pulse {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(16, 185, 129, 0.25);
animation: fab-ring 2.4s ease-out infinite;
pointer-events: none;
}
.fab-pulse-2 {
animation-delay: 1.2s;
}
@keyframes fab-ring {
0% {
transform: scale(0.95);
opacity: 0.7;
}
100% {
transform: scale(1.55);
opacity: 0;
}
}
/* ============================================================
* 展开菜单
* ============================================================ */
.menu-list {
display: flex;
flex-direction: column;
gap: 14rpx;
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20rpx) scale(0.92);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.menu-item {
display: flex;
align-items: center;
gap: 16rpx;
background: rgba(255, 255, 255, 0.96);
padding: 14rpx 20rpx 14rpx 14rpx;
border-radius: 999rpx;
box-shadow:
0 8rpx 24rpx rgba(15, 23, 42, 0.1),
0 1rpx 2rpx rgba(15, 23, 42, 0.06);
min-width: 280rpx;
transition: transform 0.15s;
backdrop-filter: blur(12rpx);
&:active {
transform: scale(0.97);
background: #f0fdf4;
}
}
.menu-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2rpx 8rpx rgba(16, 185, 129, 0.32);
&--train {
background: linear-gradient(140deg, $brand-light, $brand-deep);
}
&--dumbbell {
background: linear-gradient(140deg, #93c5fd, #3b82f6);
box-shadow: 0 2rpx 8rpx rgba(59, 130, 246, 0.36);
}
&--pedal {
background: linear-gradient(140deg, #fdba74, #f97316);
box-shadow: 0 2rpx 8rpx rgba(249, 115, 22, 0.36);
}
&--pilates {
background: linear-gradient(140deg, #c4b5fd, #8b5cf6);
box-shadow: 0 2rpx 8rpx rgba(139, 92, 246, 0.36);
}
&--grip {
background: linear-gradient(140deg, #5eead4, #14b8a6);
box-shadow: 0 2rpx 8rpx rgba(20, 184, 166, 0.36);
}
&--metro {
background: linear-gradient(140deg, #fbbf24, #d97706);
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
gap: 4rpx;
}
}
.menu-icon-text {
font-size: 32rpx;
line-height: 1;
}
/* 哑铃小 icon */
.dumbbell-shape {
width: 40rpx;
height: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.dumbbell-plate {
width: 10rpx;
height: 18rpx;
background: #fff;
border-radius: 3rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.dumbbell-bar {
width: 16rpx;
height: 5rpx;
background: #fff;
border-radius: 2rpx;
margin: 0 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
/* 脚蹬器小 icon: 左右踏板 */
.foot-pedal-shape {
width: 40rpx;
height: 28rpx;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 6rpx;
}
.pedal-bar {
width: 14rpx;
background: #fff;
border-radius: 4rpx 4rpx 2rpx 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.pedal-bar-left {
height: 18rpx;
transform: rotate(-12deg);
}
.pedal-bar-right {
height: 22rpx;
transform: rotate(12deg);
}
/* 瑜伽环小 icon: 双弧阻力环 */
.pilates-ring-shape {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 5rpx solid #fff;
position: relative;
box-shadow:
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 18rpx;
height: 18rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 3rpx solid rgba(255, 255, 255, 0.85);
}
}
/* 握力环小 icon: 白色描边的环(甜甜圈) */
.grip-ring-shape {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 7rpx solid #fff;
box-shadow:
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
}
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
.metro-bar {
width: 4rpx;
background: #fff;
border-radius: 2rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
}
.metro-bar-1 {
height: 16rpx;
}
.metro-bar-2 {
height: 28rpx;
}
.metro-bar-3 {
height: 22rpx;
}
.menu-info {
display: flex;
flex-direction: column;
gap: 2rpx;
flex: 1;
}
.menu-title {
font-size: 28rpx;
color: #0f172a;
font-weight: 700;
letter-spacing: 1rpx;
}
.menu-sub {
font-size: 20rpx;
color: #94a3b8;
letter-spacing: 0.5rpx;
}
.menu-arrow {
font-size: 32rpx;
color: #cbd5e1;
font-weight: 300;
line-height: 1;
margin-left: 4rpx;
}
</style>
+10 -4
View File
@@ -1,5 +1,11 @@
import App from './App'
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,
+9 -1
View File
@@ -57,7 +57,15 @@
"optimization" : {
"subPackages" : true
},
"usingComponents" : true
"usingComponents" : true,
"requiredBackgroundModes" : ["audio"],
"plugins" : {
"WechatSI" : {
"version" : "0.3.5",
"provider" : "wx069ba97219f66d99"
}
}
},
"mp-alipay" : {
"usingComponents" : true
+10540 -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"
}
}
+100
View File
@@ -105,6 +105,106 @@
}
}
]
},
{
"root": "training",
"pages": [
{
"path": "pages/grip-ring",
"style": {
"navigationBarTitleText": "握力环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/pilates-ring",
"style": {
"navigationBarTitleText": "瑜伽环训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/foot-pedal",
"style": {
"navigationBarTitleText": "脚蹬器训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/dumbbell",
"style": {
"navigationBarTitleText": "哑铃训练",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
},
{
"path": "pages/metronome",
"style": {
"navigationBarTitleText": "耗糖节拍器",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc"
}
}
]
},
{
"root": "tongji",
"pages": [
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "血糖记录",
"navigationBarBackgroundColor": "#204e2b",
"navigationBarTextStyle": "white",
"backgroundColor": "#faf9f5",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/more",
"style": {
"navigationBarTitleText": "日常护理",
"navigationBarBackgroundColor": "#204e2b",
"navigationBarTextStyle": "white",
"backgroundColor": "#faf9f5",
"enablePullDownRefresh": true
}
},
{
"path": "pages/weekly",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "血糖管理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
}
]
}
],
"tabBar": {
+70
View File
@@ -53,6 +53,18 @@
<text class="time-value">{{ card.create_time || '-' }}</text>
</view>
</view>
<!-- 快捷入口 -->
<view class="card-actions">
<view class="card-action-btn primary" @click.stop="viewDailyRecord(card)">
<text class="card-action-icon">📈</text>
<text class="card-action-text">日常记录</text>
</view>
<view class="card-action-btn" @click.stop="viewCardDetail(card)">
<text class="card-action-icon">📝</text>
<text class="card-action-text">查看 / 编辑</text>
</view>
</view>
</view>
<!-- 空状态 -->
@@ -138,6 +150,16 @@ const viewCardDetail = (card) => {
})
}
// 查看日常记录(血糖血压 / 饮食 / 运动 / 跟踪备注 + 波浪图)
const viewDailyRecord = (card) => {
const url = `/tongji/pages/index?diagnosis_id=${card.id}` +
`&patient_id=${card.patient_id || ''}` +
`&patient_name=${encodeURIComponent(card.patient_name || '')}` +
`&age=${card.age || ''}` +
`&gender=${card.gender || ''}`
uni.navigateTo({ url })
}
// 新建就诊卡(patient_id 创建后自动生成,仅需登录)
const createCard = () => {
const token = uni.getStorageSync('token')
@@ -310,6 +332,54 @@ const formatDate = (timestamp) => {
border-top: 4rpx solid #e8eaed;
}
.card-actions {
display: flex;
gap: 20rpx;
margin-top: 28rpx;
padding-top: 28rpx;
border-top: 4rpx solid #e8eaed;
}
.card-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 24rpx 12rpx;
background: #f6faff;
border-radius: 20rpx;
border: 2rpx solid #d6e6fb;
min-height: 80rpx;
transition: all 0.2s;
&:active {
transform: scale(0.98);
opacity: 0.85;
}
&.primary {
background: linear-gradient(135deg, #1890ff, #0ea5a4);
border-color: transparent;
.card-action-icon,
.card-action-text {
color: #ffffff;
}
}
}
.card-action-icon {
font-size: 36rpx;
color: #1890ff;
}
.card-action-text {
font-size: 30rpx;
color: #1890ff;
font-weight: 500;
}
.time-info {
display: flex;
align-items: center;
+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
View File
@@ -88,13 +88,18 @@
</div>
</div>
</div>
<!-- DEV 开发悬浮入口生产环境自动隐藏 -->
<dev-training-entry />
</div>
</template>
<script>
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
export default {
name: 'profileB',
components: { DevTrainingEntry },
data() {
return {
userInfo: {
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,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,176 @@
<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"/>'
}
/** 图片加载失败时的 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': ''
}
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%;
}
.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,115 @@
import { ref } from 'vue'
import { formatUserMessage } from '../utils/tongjiHelpers.js'
/** 登录 / 就诊卡门禁(index 与 more 共用) */
export function useTongjiAuth(proxy) {
const authChecking = ref(false)
let gateRedirected = false
let authSessionPromise = null
function hasAuthToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearAuthStorage() {
uni.removeStorageSync('token')
authSessionPromise = null
}
function wxLoginGetCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (res && res.code) resolve(res.code)
else reject(new Error('微信登录未返回 code'))
},
fail: reject
})
})
}
async function mnpLoginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res && res.code === 1 && res.data && res.data.token) {
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return res.data
}
clearAuthStorage()
throw new Error(formatUserMessage(res?.msg, '登录失败'))
}
async function doWxLogin() {
const code = await wxLoginGetCode()
await mnpLoginWithCode(code)
return hasAuthToken()
}
async function verifyOrLogin() {
if (!hasAuthToken()) {
return doWxLogin()
}
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res && res.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
} catch (e) {
/* 网络异常走重新登录 */
}
clearAuthStorage()
try {
return await doWxLogin()
} catch (e2) {
return false
}
}
async function ensureLoggedIn() {
if (authSessionPromise) {
return authSessionPromise
}
authSessionPromise = verifyOrLogin()
.then((ok) => {
if (!ok) authSessionPromise = null
return !!ok
})
.catch(() => {
authSessionPromise = null
return false
})
return authSessionPromise
}
function redirectToCardEntry(returnPath) {
if (gateRedirected) return
gateRedirected = true
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
uni.redirectTo({
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
})
}
function resetGateRedirect() {
gateRedirected = false
}
function isGateRedirected() {
return gateRedirected
}
return {
authChecking,
hasAuthToken,
ensureLoggedIn,
redirectToCardEntry,
resetGateRedirect,
isGateRedirected
}
}
@@ -0,0 +1,28 @@
# VitalMint Health (Stitch)
Source: Google Stitch MCP · project `Modern WeChat UI Redesign`
Screen HTML: `design-system/stitch-weekly.html`
## Colors
- background / surface: `#f4fbf4`
- primary: `#006c49`
- primary-container: `#10b981`
- tertiary-container (餐后): `#fc7c78`
- on-surface: `#161d19`
- error (high glucose): `#ba1a1a`
- warning: `#ea580c`
## Layout (weekly.vue)
1. TopAppBar — 头像 + 问候 + 朗读按钮
2. 录入今日血糖 — 全宽圆角主按钮
3. 今日血糖 — 双列卡片 + 查看更多
4. 血糖趋势 — 单卡片内含统计、图表、7/30 切换
5. AI 饮食 — 渐变边框白底卡片
6. 能不能吃 — 独立卡片
## Styles
- `styles/stitch-vitalmint-theme.scss` — 设计 token
- `styles/weekly-stitch.scss` — 页面组件样式 (vm-*)
@@ -0,0 +1,398 @@
<!DOCTYPE html><html lang="zh-CN" style=""><head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>血糖管理</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&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 @@
{"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
@@ -0,0 +1,993 @@
/**
* 日常记录统一主题 与甄养堂小程序 TCM Care 品牌色一致
* 来源pages/index/index.vue · tabBar selectedColor #204e2b
*/
.daily-page {
/* 品牌主色 */
--primary: #204e2b;
--primary-dark: #163d22;
--primary-container: #386641;
--primary-grad: linear-gradient(160deg, #204e2b 0%, #386641 100%);
--primary-light: #eef6ef;
--primary-soft: #cfe8d3;
--primary-glow: rgba(32, 78, 43, 0.14);
--on-primary-container: #afe2b3;
--success: #386641;
--success-light: #eef6ef;
--danger: #dc2626;
--danger-light: #fef2f2;
--danger-glow: rgba(220, 38, 38, 0.1);
--warning: #d97706;
--warning-light: #fff7ed;
--text-primary: #1b1c1a;
--text-secondary: #414941;
--text-muted: #727970;
--surface: #ffffff;
--surface-muted: #f4f4f0;
--border: #e3e2df;
--border-soft: rgba(32, 78, 43, 0.12);
--slate-50: #faf9f5;
--slate-100: #f4f4f0;
--slate-200: #e9e8e4;
--slate-300: #e3e2df;
--slate-400: #727970;
--slate-600: #414941;
--slate-900: #1b1c1a;
--shadow-premium: 0 8rpx 28rpx rgba(32, 78, 43, 0.06);
--shadow-sm: 0 4rpx 16rpx rgba(32, 78, 43, 0.05);
--shadow-glow-primary: 0 8rpx 20rpx rgba(32, 78, 43, 0.18);
--shadow-glow-danger: 0 8rpx 20rpx rgba(220, 38, 38, 0.12);
--border-premium: 1rpx solid var(--border-soft);
background: var(--slate-50);
}
/* Hero:品牌绿渐变 */
.daily-page .hero-bg {
background: var(--primary-grad);
&::after {
background: radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.1) 0%, transparent 55%);
}
}
.daily-page .hero-orb {
display: none;
}
.daily-page .hero-pill.voice.speaking {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(255, 255, 255, 0.95);
animation: none;
box-shadow: 0 0 0 3rpx rgba(255, 255, 255, 0.35);
}
.daily-page .hero-status-chip.pending {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.28);
}
.daily-page .hero-status-chip.done {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.32);
}
/* 指标卡 */
.daily-page .stat-fasting,
.daily-page .stat-postprandial,
.daily-page .stat-other {
background: var(--surface);
}
.daily-page .stat-fasting .stat-card-tag,
.daily-page .stat-postprandial .stat-card-tag,
.daily-page .stat-other .stat-card-tag {
background: var(--primary-light);
color: var(--primary-dark);
}
.daily-page .stat-card-value {
background: none;
-webkit-background-clip: unset;
background-clip: unset;
color: var(--text-primary);
}
.daily-page .stat-card-value.is-high {
background: none;
color: var(--danger);
}
.daily-page .stat-card-trend.trend-down {
background: var(--primary-light);
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: var(--primary-dark);
}
}
.daily-page .more-entry-arrow {
color: var(--primary);
}
.daily-page .card-chip.active {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .range-item.active {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .legend-dot-fasting {
background: var(--primary);
}
.daily-page .legend-dot-postprandial {
background: var(--warning);
}
.daily-page .streak-strip {
background: var(--surface);
border-color: var(--border-soft);
&::after {
background: radial-gradient(circle, rgba(32, 78, 43, 0.08) 0%, transparent 70%);
}
}
.daily-page .streak-num {
color: var(--primary-dark);
}
/* 日历:品牌绿深浅 */
.daily-page .calendar-cell.level-1 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-1 {
background: var(--primary-soft);
border-color: #b8dcc0;
}
.daily-page .calendar-cell.level-2 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-2 {
background: var(--on-primary-container);
border-color: #86c992;
}
.daily-page .calendar-cell.level-3 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-3 {
background: var(--primary);
border-color: var(--primary-dark);
}
.daily-page .day-block-tag.tag-blood,
.daily-page .day-block-tag.tag-diet,
.daily-page .day-block-tag.tag-exercise,
.daily-page .day-block-tag.tag-tracking {
background: var(--primary-light);
color: var(--primary-dark);
border: 1rpx solid var(--border-soft);
}
.daily-page .day-block-tag.tag-diet,
.daily-page .day-block-tag.tag-exercise {
background: var(--surface-muted);
color: var(--text-secondary);
border-color: var(--border);
}
.daily-page .pg-points-badge {
background: var(--primary-light);
border-color: var(--border-soft);
}
.daily-page .pg-points-num {
color: var(--primary-dark);
}
.daily-page .record-quick-chip.pending {
border-color: var(--border);
background: var(--surface-muted);
}
.daily-page .record-quick-chip.done {
border-color: rgba(56, 102, 65, 0.35);
background: var(--success-light);
}
.daily-page .task-action-btn.pending {
background: var(--primary);
}
.daily-page .water-btn {
background: var(--primary);
}
.daily-page .family-like-strip,
.daily-page .encourage-strip {
background: var(--surface);
border: 1rpx solid var(--border);
box-shadow: var(--shadow-premium);
}
.daily-page .card-title,
.daily-page .hero-title {
color: inherit;
}
.daily-page .report-share-btn,
.daily-page .report-share-btn::after {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .input-modal-btn.primary {
background: var(--primary);
}
.daily-page .input-field-dot-fasting {
background: var(--primary);
}
.daily-page .input-field-dot-postprandial {
background: var(--primary-container);
}
.daily-page .input-field-dot-other {
background: var(--text-muted);
}
.daily-page .voice-switch.on .voice-switch-track {
background: var(--primary);
}
.daily-page .heatmap-foot-cta {
background: var(--primary);
}
/* ========== 适老极简首页(index ========== */
.daily-page .elder-header {
padding: 48rpx 32rpx 24rpx;
}
.daily-page .elder-greet {
display: block;
font-size: 44rpx;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
}
.daily-page .elder-status {
display: block;
margin-top: 16rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1.4;
&.is-done {
color: var(--success);
}
&.is-pending {
color: var(--text-secondary);
}
}
.daily-page .elder-card-scroll {
margin-top: 24rpx;
width: 100%;
white-space: nowrap;
}
.daily-page .elder-card-row {
display: inline-flex;
flex-wrap: nowrap;
gap: 16rpx;
padding: 2rpx 0;
}
.daily-page .elder-card-chip {
flex-shrink: 0;
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: var(--surface);
border: 2rpx solid var(--border);
text {
font-size: 28rpx;
color: var(--text-secondary);
}
&.active {
background: var(--primary);
border-color: var(--primary);
text {
color: #fff;
font-weight: 600;
}
}
}
.daily-page .elder-actions {
padding: 0 32rpx 24rpx;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 16rpx;
}
.daily-page .elder-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
min-height: 112rpx;
min-width: 0;
border-radius: 24rpx;
box-shadow: var(--shadow-sm);
box-sizing: border-box;
}
.daily-page .elder-btn-text {
font-size: 36rpx;
font-weight: 700;
}
.daily-page .elder-btn-primary {
flex: 8;
background: var(--primary);
.elder-btn-text {
color: #fff;
}
}
.daily-page .elder-btn-voice {
flex: 2;
flex-direction: column;
gap: 8rpx;
padding: 12rpx 8rpx;
background: var(--surface);
border: 2rpx solid var(--border-soft);
.elder-btn-text {
color: var(--primary-dark);
font-size: 24rpx;
line-height: 1.25;
text-align: center;
}
&.speaking {
border-color: var(--primary);
background: var(--primary-light);
}
&.disabled {
opacity: 0.45;
}
}
.daily-page .elder-today-panel {
margin: 0 32rpx 24rpx;
padding: 32rpx;
background: var(--surface);
border-radius: 24rpx;
border: 1rpx solid var(--border);
box-shadow: var(--shadow-premium);
}
.daily-page .elder-panel-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24rpx;
}
.daily-page .elder-today-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.daily-page .elder-metric {
padding: 24rpx;
border-radius: 20rpx;
background: var(--surface-muted);
border: 2rpx solid var(--border);
&.is-high {
border-color: rgba(220, 38, 38, 0.35);
background: var(--danger-light);
}
}
.daily-page .elder-metric-label {
display: block;
font-size: 28rpx;
color: var(--text-secondary);
margin-bottom: 12rpx;
}
.daily-page .elder-metric-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.daily-page .elder-metric-value {
font-size: 56rpx;
font-weight: 800;
color: var(--text-primary);
line-height: 1.1;
}
.daily-page .elder-metric.is-high .elder-metric-value {
color: var(--danger);
}
.daily-page .elder-metric-unit {
font-size: 26rpx;
color: var(--text-muted);
}
.daily-page .elder-metric-flag {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
color: var(--danger);
font-weight: 600;
}
.daily-page .elder-more-link {
margin: 0 32rpx 16rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface);
border-radius: 24rpx;
border: 1rpx solid var(--border);
}
.daily-page .elder-more-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: var(--text-primary);
}
.daily-page .elder-more-sub {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: var(--text-muted);
line-height: 1.45;
}
.daily-page .elder-more-arrow {
font-size: 48rpx;
color: var(--primary);
font-weight: 300;
padding-left: 16rpx;
}
.daily-page .footer-tip {
text-align: center;
font-size: 26rpx;
color: var(--text-muted);
padding: 16rpx 32rpx 48rpx;
line-height: 1.6;
}
.daily-page .elder-chart-block .range-bar {
margin-bottom: 16rpx;
}
.daily-page .glucose-history-block {
padding-bottom: 8rpx;
}
@@ -0,0 +1,47 @@
/**
* Google Stitch MCP · VitalMint Health
* 设计 token stitch-weekly.html tailwind.config 一一对应
*/
.weekly-page {
--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;
--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);
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: 999;
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,655 @@
/**
* 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);
}
/* ===== 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);
}
/* ===== 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);
}
/* ===== Chart card — rounded-3xl ===== */
.weekly-page .st-chart-card {
padding: 40rpx;
border-radius: 48rpx;
background: var(--surface-container-lowest);
border: 1rpx solid rgba(221, 228, 221, 0.3);
box-shadow: var(--st-shadow-ambient);
}
.weekly-page .st-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 48rpx;
}
.weekly-page .st-chart-title {
display: block;
font-size: 40rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-chart-sub {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.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-area {
position: relative;
height: 360rpx;
border-bottom: 1rpx dashed var(--surface-container-highest);
}
.weekly-page .st-chart-canvas {
width: 100%;
height: 360rpx;
display: block;
}
.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: calc(24rpx + env(safe-area-inset-bottom));
z-index: 90;
}
.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-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);
}
.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)]
}
@@ -0,0 +1,278 @@
import { ref, computed, onUnmounted } from 'vue'
export interface MetronomeOptions {
initialBpm?: number
bpmMin?: number
bpmMax?: number
clickSrc?: string
accentSrc?: string
accentEvery?: number
poolSize?: number
volume?: number // 节拍音音量 0-1
silent?: boolean // 静音模式:只驱动节拍回调,不发声(如握力环只需视觉节拍)
onBeat?: (beatIndex: number, isAccent: boolean) => void
}
/**
* click InnerAudioContext
*
* 1. seek+play 100-300ms
* 2. BPM play
*/
class AudioPool {
private list: UniApp.InnerAudioContext[] = []
private cursor = 0
private warmedUp = false
private targetVolume = 1
constructor(
private src: string,
private size: number,
volume = 1,
) {
this.targetVolume = volume
}
private create() {
for (let i = 0; i < this.size; i++) {
const ctx = uni.createInnerAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.targetVolume
this.list.push(ctx)
}
}
setVolume(volume: number) {
this.targetVolume = Math.max(0, Math.min(1, volume))
this.list.forEach((ctx) => {
try {
ctx.volume = this.targetVolume
} catch (_) {}
})
}
/**
* ,/
* play
*/
warmUp() {
if (this.warmedUp) return
if (this.list.length === 0) this.create()
this.list.forEach((ctx) => {
try {
ctx.volume = 0 // 静音预热,避免进入页面时响声
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = this.targetVolume // 恢复目标音量
} catch (_) {}
}, 60)
} catch (_) {}
})
this.warmedUp = true
}
play() {
if (this.list.length === 0) {
this.create()
this.warmUp()
}
const ctx = this.list[this.cursor]
this.cursor = (this.cursor + 1) % this.list.length
try {
// 强制停止所有正在播放的音频,避免重叠
this.list.forEach(c => {
try { c.stop() } catch (_) {}
})
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
ctx.play()
} catch (_) {
try { ctx.play() } catch (__) {}
}
}
destroy() {
this.list.forEach((ctx) => {
try {
ctx.destroy?.()
} catch (_) {}
})
this.list = []
this.warmedUp = false
}
}
/* InnerAudio URL, CDN, BgAudio 便
~3KB, 100~300ms ,, */
const DEFAULT_CLICK_SRC =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128557ef8669.mp3'
export function useMetronome(options: MetronomeOptions = {}) {
const {
initialBpm = 80,
bpmMin = 40,
bpmMax = 240,
clickSrc = DEFAULT_CLICK_SRC,
accentSrc,
poolSize = 4,
volume = 1,
silent = false,
onBeat,
} = options
const bpm = ref<number>(initialBpm)
const isPlaying = ref<boolean>(false)
const beatIndex = ref<number>(0)
const isAccent = ref<boolean>(false)
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
const intervalMs = computed(() => 60000 / bpm.value)
let clickPool: AudioPool | null = null
let accentPool: AudioPool | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let nextTickAt = 0
const ensureAudio = () => {
if (silent) return
if (!clickPool) {
clickPool = new AudioPool(clickSrc, poolSize, volume)
clickPool.warmUp()
}
if (accentSrc && !accentPool) {
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)), volume)
accentPool.warmUp()
}
}
const playSound = (accent: boolean) => {
if (accent && accentPool) {
accentPool.play()
} else if (clickPool) {
clickPool.play()
}
}
const tick = () => {
if (!isPlaying.value) return
const every = Math.max(1, accentEveryRef.value)
const accent = beatIndex.value % every === 0
isAccent.value = accent
playSound(accent)
onBeat?.(beatIndex.value, accent)
beatIndex.value++
nextTickAt += intervalMs.value
const nextDelay = Math.max(0, nextTickAt - Date.now())
timer = setTimeout(tick, nextDelay)
}
const start = () => {
if (isPlaying.value) return
ensureAudio()
isPlaying.value = true
beatIndex.value = 0
nextTickAt = Date.now()
tick()
}
const stop = () => {
isPlaying.value = false
if (timer) {
clearTimeout(timer)
timer = null
}
}
const setBpm = (val: number) => {
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
}
const setAccentEvery = (n: number) => {
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
beatIndex.value = 0
}
/**
*
*/
const updateAudioSrc = (newClickSrc: string, newAccentSrc?: string) => {
const wasPlaying = isPlaying.value
if (wasPlaying) stop()
// 销毁旧的音频池
clickPool?.destroy()
accentPool?.destroy()
clickPool = null
accentPool = null
// 创建新的音频池(静默预热,不播放)
clickPool = new AudioPool(newClickSrc, poolSize)
if (newAccentSrc) {
accentPool = new AudioPool(newAccentSrc, Math.max(2, Math.ceil(poolSize / 2)))
}
// 播放一次预览音效(适中音量)
const previewCtx = uni.createInnerAudioContext()
previewCtx.src = newClickSrc
previewCtx.obeyMuteSwitch = false
previewCtx.volume = 0.4
try {
previewCtx.play()
setTimeout(() => {
try {
previewCtx.destroy?.()
} catch (_) {}
}, 500)
} catch (_) {}
// 恢复播放状态
if (wasPlaying) start()
}
/**
* onShow
* setInnerAudioOption, App.vue (iOS )
*/
const preload = () => {
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true,
})
} catch (_) {}
// #endif
ensureAudio()
}
onUnmounted(() => {
stop()
clickPool?.destroy()
accentPool?.destroy()
clickPool = null
accentPool = null
})
return {
bpm,
isPlaying,
beatIndex,
isAccent,
intervalMs,
accentEvery: accentEveryRef,
start,
stop,
setBpm,
setAccentEvery,
updateAudioSrc,
preload,
}
}
@@ -0,0 +1,675 @@
<template>
<view class="crush-canvas-container">
<canvas
type="2d"
id="crush"
class="crush-canvas"
></canvas>
</view>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
// ============================================================
//
// ============================================================
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
// ()
interface Debris {
x: number
y: number
vx: number
vy: number
size: number
color: string
shape: ParticleShape
rotation: number
rotationSpeed: number
life: number
decay: number
gravity: number
glow: boolean
swing: number // (confetti ),0
age: number
}
// (,)
interface Spark {
x: number
y: number
px: number // ()
py: number
vx: number
vy: number
size: number
color: string
life: number
decay: number
gravity: number
}
// ()
interface Sparkle {
x: number
y: number
size: number
rotation: number
spin: number
color: string
life: number
decay: number
}
//
interface Flash {
x: number
y: number
radius: number
maxRadius: number
color: string
life: number
decay: number
}
interface Shockwave {
x: number
y: number
radius: number
maxRadius: number
color: string
width: number
life: number
decay: number
}
interface CrushItemConfig {
colors: string[] //
sparkColors: string[] // /()
shapes: ParticleShape[]
debrisCount: number
sparkCount: number
sparkleCount: number
speedMin: number
speedMax: number
sizeMin: number
sizeMax: number
gravity: number
flashColor: string //
shockwaveColor: string
confetti?: boolean // balloon:
}
// ============================================================
//
// ============================================================
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
egg: {
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFD54F', '#FFC107'],
sparkColors: ['#FFFFFF', '#FFF9C4', '#FFEE58'],
shapes: ['circle', 'circle', 'circle'],
debrisCount: 20,
sparkCount: 16,
sparkleCount: 5,
speedMin: 3,
speedMax: 6,
sizeMin: 4,
sizeMax: 10,
gravity: 0.34,
flashColor: '#FFFDE7',
shockwaveColor: 'rgba(255, 224, 130, 0.85)',
},
walnut: {
colors: ['#5D4037', '#795548', '#8D6E63', '#A1887F', '#3E2723'],
sparkColors: ['#FFCC80', '#FFB74D', '#FFE0B2'],
shapes: ['square', 'triangle', 'rect'],
debrisCount: 22,
sparkCount: 18,
sparkleCount: 5,
speedMin: 2.8,
speedMax: 5,
sizeMin: 4,
sizeMax: 9,
gravity: 0.42,
flashColor: '#FFE0B2',
shockwaveColor: 'rgba(255, 167, 38, 0.8)',
},
can: {
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1', '#607D8B'],
sparkColors: ['#FFFFFF', '#E1F5FE', '#B3E5FC'],
shapes: ['rect', 'rect', 'triangle', 'square'],
debrisCount: 24,
sparkCount: 20,
sparkleCount: 6,
speedMin: 4,
speedMax: 7,
sizeMin: 3,
sizeMax: 11,
gravity: 0.36,
flashColor: '#FFFFFF',
shockwaveColor: 'rgba(207, 216, 220, 0.9)',
},
balloon: {
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE', '#FF6E40'],
sparkColors: ['#FFFFFF', '#FF80AB', '#82B1FF', '#FFFF8D'],
shapes: ['rect', 'rect', 'square'],
debrisCount: 28,
sparkCount: 18,
sparkleCount: 8,
speedMin: 4,
speedMax: 7.5,
sizeMin: 4,
sizeMax: 8,
gravity: 0.14,
flashColor: '#FCE4EC',
shockwaveColor: 'rgba(255, 64, 129, 0.85)',
confetti: true,
},
}
const PHYSICS = {
AIR_RESISTANCE: 0.985,
SPARK_RESISTANCE: 0.92,
DEBRIS_DECAY: 0.018,
SPARK_DECAY: 0.035,
SPARKLE_DECAY: 0.045,
FLASH_DECAY: 0.11,
SHOCKWAVE_DECAY: 0.045,
}
// ============================================================
//
// ============================================================
const canvasNode = ref<any>(null)
const ctx = ref<any>(null)
const canvasWidth = ref<number>(240)
const canvasHeight = ref<number>(240)
const dpr = ref<number>(1)
const rafId = ref<number | null>(null)
const renderRunning = ref<boolean>(false)
const debrisList = ref<Debris[]>([])
const sparkList = ref<Spark[]>([])
const sparkleList = ref<Sparkle[]>([])
const flashList = ref<Flash[]>([])
const shockwaves = ref<Shockwave[]>([])
const instance = getCurrentInstance()
// prop ( ref , )
const props = defineProps<{
crushSignal?: { type: CrushItemType; nonce: number } | null
}>()
watch(
() => props.crushSignal,
(sig) => {
if (sig) triggerCrush(sig.type)
},
)
onMounted(() => {
initCanvas()
})
onBeforeUnmount(() => {
cleanup()
})
// ============================================================
// Canvas 2D
// ============================================================
function initCanvas() {
setTimeout(() => {
if (!instance) return
const query = uni.createSelectorQuery().in(instance)
query.select('#crush')
.fields({ node: true, size: true } as any)
.exec((res: any[]) => {
if (!res || !res[0] || !res[0].node) {
// : node(),
console.warn('[crush-canvas] canvas 2d not supported on this platform')
return
}
const canvas = res[0].node
const width = res[0].width
const height = res[0].height
//
const systemInfo = uni.getSystemInfoSync()
const pixelRatio = systemInfo.pixelRatio || 1
// canvas ()
canvas.width = width * pixelRatio
canvas.height = height * pixelRatio
// 2D
const context = canvas.getContext('2d')
context.scale(pixelRatio, pixelRatio)
//
canvasNode.value = canvas
ctx.value = context
canvasWidth.value = width
canvasHeight.value = height
dpr.value = pixelRatio
})
}, 200)
}
// ============================================================
//
// ============================================================
function triggerCrush(itemType: CrushItemType) {
if (!ctx.value || !canvasNode.value) return
const item = CRUSH_ITEMS[itemType]
if (!item) return
const centerX = canvasWidth.value / 2
const centerY = canvasHeight.value / 2
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
// 1.
flashList.value.push({
x: centerX,
y: centerY,
radius: maxR * 0.12,
maxRadius: maxR * 0.62,
color: item.flashColor,
life: 1.0,
decay: PHYSICS.FLASH_DECAY,
})
// 2.
shockwaves.value.push({
x: centerX, y: centerY,
radius: 12, maxRadius: maxR * 0.62,
color: item.shockwaveColor, width: 5,
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
})
shockwaves.value.push({
x: centerX, y: centerY,
radius: 4, maxRadius: maxR * 0.42,
color: item.shockwaveColor, width: 3,
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
})
// 3. ()
for (let i = 0; i < item.debrisCount; i++) {
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
// confetti(): +
const confetti = !!item.confetti
const vy0 = confetti
? Math.sin(angle) * speed - 2.5 //
: Math.sin(angle) * speed
debrisList.value.push({
x: centerX,
y: centerY,
vx: Math.cos(angle) * speed,
vy: vy0,
size,
color,
shape,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
life: 1.0,
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
gravity: item.gravity,
glow: !confetti, // ,
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
age: Math.random() * Math.PI * 2,
})
}
// 4. (,)
for (let i = 0; i < item.sparkCount; i++) {
const angle = Math.random() * Math.PI * 2
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
sparkList.value.push({
x: centerX,
y: centerY,
px: centerX,
py: centerY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 1.5 + Math.random() * 2,
color,
life: 1.0,
decay: PHYSICS.SPARK_DECAY * (0.8 + Math.random() * 0.6),
gravity: item.gravity * 0.3,
})
}
// 5.
for (let i = 0; i < item.sparkleCount; i++) {
const r = maxR * (0.08 + Math.random() * 0.32)
const a = Math.random() * Math.PI * 2
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
sparkleList.value.push({
x: centerX + Math.cos(a) * r,
y: centerY + Math.sin(a) * r,
size: 10 + Math.random() * 16,
rotation: Math.random() * Math.PI,
spin: (Math.random() - 0.5) * 0.16,
color,
life: 1.0 + Math.random() * 0.4, //
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
})
}
ensureRenderLoop()
}
// ============================================================
//
// ============================================================
function ensureRenderLoop() {
if (renderRunning.value) return
renderRunning.value = true
renderLoop()
}
function renderLoop() {
if (!canvasNode.value || !ctx.value) {
renderRunning.value = false
return
}
const w = canvasWidth.value
const h = canvasHeight.value
//
for (let i = debrisList.value.length - 1; i >= 0; i--) {
const p = debrisList.value[i]
p.vy += p.gravity
p.vx *= PHYSICS.AIR_RESISTANCE
p.vy *= PHYSICS.AIR_RESISTANCE
p.x += p.vx
p.y += p.vy
p.age += 0.2
if (p.swing > 0) p.x += Math.sin(p.age) * p.swing //
p.rotation += p.rotationSpeed
p.life -= p.decay
if (p.life <= 0 || p.x < -60 || p.x > w + 60 || p.y > h + 60) {
debrisList.value.splice(i, 1)
}
}
//
for (let i = sparkList.value.length - 1; i >= 0; i--) {
const s = sparkList.value[i]
s.px = s.x
s.py = s.y
s.vy += s.gravity
s.vx *= PHYSICS.SPARK_RESISTANCE
s.vy *= PHYSICS.SPARK_RESISTANCE
s.x += s.vx
s.y += s.vy
s.life -= s.decay
if (s.life <= 0) sparkList.value.splice(i, 1)
}
//
for (let i = sparkleList.value.length - 1; i >= 0; i--) {
const sp = sparkleList.value[i]
sp.rotation += sp.spin
sp.life -= sp.decay
if (sp.life <= 0) sparkleList.value.splice(i, 1)
}
//
for (let i = flashList.value.length - 1; i >= 0; i--) {
const f = flashList.value[i]
f.radius += (f.maxRadius - f.radius) * 0.35
f.life -= f.decay
if (f.life <= 0) flashList.value.splice(i, 1)
}
//
for (let i = shockwaves.value.length - 1; i >= 0; i--) {
const sw = shockwaves.value[i]
sw.radius += (sw.maxRadius - sw.radius) * 0.16
sw.life -= sw.decay
if (sw.life <= 0) shockwaves.value.splice(i, 1)
}
draw()
const alive =
debrisList.value.length > 0 ||
sparkList.value.length > 0 ||
sparkleList.value.length > 0 ||
flashList.value.length > 0 ||
shockwaves.value.length > 0
if (alive) {
rafId.value = canvasNode.value.requestAnimationFrame(renderLoop) as unknown as number
} else {
rafId.value = null
renderRunning.value = false
}
}
// ============================================================
//
// ============================================================
function draw() {
const c = ctx.value
const w = canvasWidth.value
const h = canvasHeight.value
c.clearRect(0, 0, w, h)
// 1. ()
c.globalCompositeOperation = 'source-over'
shockwaves.value.forEach(sw => {
c.beginPath()
c.arc(sw.x, sw.y, sw.radius, 0, Math.PI * 2)
c.strokeStyle = applyAlphaToRgba(sw.color, Math.min(1, sw.life))
c.lineWidth = sw.width * sw.life
c.stroke()
})
// 2. ()
c.globalCompositeOperation = 'lighter'
flashList.value.forEach(f => {
const grd = c.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.radius)
const a = Math.min(1, f.life)
grd.addColorStop(0, hexToRgba(f.color, 0.95 * a))
grd.addColorStop(0.4, hexToRgba(f.color, 0.5 * a))
grd.addColorStop(1, hexToRgba(f.color, 0))
c.fillStyle = grd
c.beginPath()
c.arc(f.x, f.y, f.radius, 0, Math.PI * 2)
c.fill()
})
// 3. (,)
c.globalCompositeOperation = 'source-over'
debrisList.value.forEach(p => {
const alpha = Math.min(1, p.life)
const currentSize = p.size * (0.65 + p.life * 0.35)
// :
if (p.glow) {
c.fillStyle = hexToRgba(p.color, alpha * 0.22)
c.beginPath()
c.arc(p.x, p.y, currentSize * 1.9, 0, Math.PI * 2)
c.fill()
}
c.fillStyle = hexToRgba(p.color, alpha)
drawDebrisShape(c, p, currentSize)
})
// 4. ( + ,)
c.globalCompositeOperation = 'lighter'
sparkList.value.forEach(s => {
const a = Math.min(1, s.life)
// 线
c.strokeStyle = hexToRgba(s.color, a * 0.8)
c.lineWidth = s.size * a
c.lineCap = 'round'
c.beginPath()
c.moveTo(s.px, s.py)
c.lineTo(s.x, s.y)
c.stroke()
//
c.fillStyle = hexToRgba(s.color, a)
c.beginPath()
c.arc(s.x, s.y, s.size * a, 0, Math.PI * 2)
c.fill()
})
// 5. (,)
sparkleList.value.forEach(sp => {
// life 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>
@@ -0,0 +1,383 @@
<template>
<view class="walker-canvas-container">
<canvas
canvas-id="walker"
id="walker"
class="walker-canvas"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchCancel"
></canvas>
</view>
</template>
<script>
export default {
props: {
isPlaying: {
type: Boolean,
default: false
},
bpm: {
type: Number,
default: 110
}
},
data() {
return {
canvasWidth: 320,
canvasHeight: 400,
ctx: null,
timer: null,
renderRunning: false,
// Interaction State (Spring Physics)
coreScale: 1,
coreTargetScale: 1,
coreVelocity: 0,
pressed: false,
// Animation Physics (Time)
time: 0,
lastFrameTime: 0,
// Metronome State
lastBeatTime: 0,
// Energy Waves
waveAmplitudeMultiplier: 1,
// Shockwaves
shockwaves: [],
// Rotation for outer ring
ringAngle: 0
};
},
mounted() {
this.initCanvas();
},
beforeUnmount() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.renderRunning = false;
},
watch: {
isPlaying(newVal) {
if (newVal) {
this.waveAmplitudeMultiplier = 1;
this.ensureRenderLoop();
}
}
},
methods: {
initCanvas() {
// Delay to ensure Flexbox layout is fully complete before measuring
setTimeout(() => {
const query = uni.createSelectorQuery().in(this);
query.select('.walker-canvas-container').boundingClientRect(data => {
if (data && data.width > 0) {
this.canvasWidth = data.width;
this.canvasHeight = data.height > 100 ? data.height : data.width * 1.2;
} else {
const sys = uni.getSystemInfoSync();
this.canvasWidth = sys.windowWidth - 30; // approximate padding
this.canvasHeight = this.canvasWidth * 1.2;
}
if (!this.ctx) {
this.ctx = uni.createCanvasContext('walker', this);
this.lastFrameTime = Date.now();
this.ensureRenderLoop();
}
}).exec();
}, 100);
},
// Expose a method to be called from parent when a beat hits
triggerBeat() {
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
// Add a shockwave
this.shockwaves.push({
radius: coreRadius,
maxRadius: coreRadius * 2.2,
opacity: 1
});
// Spike the wave amplitude
this.waveAmplitudeMultiplier = 2.5;
// Slight pulse to the core
this.coreVelocity -= 0.05;
this.ensureRenderLoop();
},
onTouchStart(e) {
const touch = e.touches[0];
if (!touch) return;
// Center
const cx = this.canvasWidth / 2;
const cy = this.canvasHeight / 2 - 30;
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
const dx = touch.x - cx;
const dy = touch.y - cy;
const dist = Math.sqrt(dx*dx + dy*dy);
// Hit box slightly larger than the visual circle
if (dist <= coreRadius + 40) {
this.pressed = true;
this.coreTargetScale = 0.9;
this.ensureRenderLoop();
}
},
onTouchMove(e) {
if (!this.pressed) return;
const touch = e.touches[0];
if (!touch) return;
const cx = this.canvasWidth / 2;
const cy = this.canvasHeight / 2 - 30;
const coreRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.32;
const dx = touch.x - cx;
const dy = touch.y - cy;
const dist = Math.sqrt(dx*dx + dy*dy);
// Slid out of the hit box - cancel the press
if (dist > coreRadius + 40) {
this.pressed = false;
this.coreTargetScale = 1;
}
},
onTouchEnd() {
// Unconditionally reset the visual scale
this.coreTargetScale = 1;
// Only emit toggle when the press has not been cancelled by drag-out
if (this.pressed) {
this.$emit('toggle-play');
}
this.pressed = false;
},
onTouchCancel() {
// System interruption (incoming call, notification pull-down, etc.):
// reset visuals only do NOT count this as a user toggle intent.
this.coreTargetScale = 1;
this.pressed = false;
},
ensureRenderLoop() {
if (this.renderRunning) return;
this.renderRunning = true;
this.renderLoop();
},
renderLoop() {
// Use a fixed time delta to prevent rubber-banding/jitter on unstable JS timers
const fixedDt = 0.016;
if (this.isPlaying) {
this.time += fixedDt;
} else {
this.time += fixedDt * 0.2; // slow drift when paused
}
// Spring Physics for Core Scale
const tension = 120;
const friction = 12;
const force = (this.coreTargetScale - this.coreScale) * tension;
this.coreVelocity += force * fixedDt;
this.coreVelocity *= Math.exp(-friction * fixedDt);
this.coreScale += this.coreVelocity * fixedDt;
// Decay wave amplitude back to 1
if (this.waveAmplitudeMultiplier > 1) {
this.waveAmplitudeMultiplier -= fixedDt * 3;
if (this.waveAmplitudeMultiplier < 1) this.waveAmplitudeMultiplier = 1;
} else if (!this.isPlaying && this.waveAmplitudeMultiplier > 0.1) {
this.waveAmplitudeMultiplier -= fixedDt * 2;
if (this.waveAmplitudeMultiplier < 0.1) this.waveAmplitudeMultiplier = 0.1;
}
this.draw();
this.ctx.draw(false);
// Decide whether to continue the render loop. Short-circuit on isPlaying first for perf.
const shouldContinue =
this.isPlaying ||
this.shockwaves.length > 0 ||
Math.abs(this.coreScale - this.coreTargetScale) >= 0.001 ||
Math.abs(this.coreVelocity) >= 0.001 ||
this.waveAmplitudeMultiplier > 0.11;
if (shouldContinue) {
// Polyfill requestAnimationFrame for uni-app
this.timer = setTimeout(() => {
this.renderLoop();
}, 1000 / 60);
} else {
this.timer = null;
this.renderRunning = false;
}
},
draw() {
const ctx = this.ctx;
const W = this.canvasWidth;
const H = this.canvasHeight;
// Clear entire canvas to ensure it is completely transparent and blends with the page background
ctx.clearRect(0, 0, W, H);
// Dynamic majestic sizing to fill the huge screen area
const coreRadius = Math.min(W, H) * 0.32; // Make the central circle MASSIVE
const ringRadius = coreRadius + 18;
const cx = W / 2;
const cy = H / 2 - 30; // Slightly above center
// 3. Draw Shockwaves (Ripples)
for (let i = this.shockwaves.length - 1; i >= 0; i--) {
const sw = this.shockwaves[i];
sw.radius += (sw.maxRadius - sw.radius) * 0.08;
sw.opacity -= 0.03;
if (sw.opacity <= 0) {
this.shockwaves.splice(i, 1);
continue;
}
ctx.beginPath();
ctx.arc(cx, cy, sw.radius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(16, 185, 129, ${sw.opacity * 0.5})`;
ctx.lineWidth = 3;
ctx.stroke();
}
// 4. Draw Energy Waves (Bottom area)
this.drawWaves(ctx, W, H);
// 5. Draw Interactive BPM Core
ctx.save();
ctx.translate(cx, cy);
ctx.scale(this.coreScale, this.coreScale);
// The Core Button (Emerald Gradient)
ctx.setShadow(0, 12, 24, 'rgba(16, 185, 129, 0.3)');
const coreGrad = ctx.createLinearGradient(-coreRadius, -coreRadius, coreRadius, coreRadius);
coreGrad.addColorStop(0, '#34d399'); // Light Mint
coreGrad.addColorStop(1, '#047857'); // Deep Emerald
ctx.beginPath();
ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);
ctx.fillStyle = coreGrad;
ctx.fill();
// Clear shadow for internal elements
ctx.setShadow(0, 0, 0, 'transparent');
// Inner Subtle Highlight (Glass edge)
ctx.beginPath();
ctx.arc(0, 0, coreRadius - 2, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)';
ctx.lineWidth = 2;
ctx.stroke();
// Text - BPM Number (MASSIVE font)
const fontSize = Math.floor(coreRadius * 0.7); // Dynamic font size based on radius
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${fontSize}px "DIN Condensed", "Inter", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Removed shadowBlur for text to save low-end GPU performance
ctx.fillText(this.bpm.toString(), 0, -12);
// Text - 'BPM' Label
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.font = '500 16px "Inter", sans-serif';
ctx.fillText('BPM', 0, coreRadius * 0.3);
// Play/Pause Icon
ctx.fillStyle = '#ffffff';
const iconY = coreRadius * 0.55;
if (this.isPlaying) {
// Pause Icon (Two vertical bars)
ctx.fillRect(-8, iconY - 6, 5, 14);
ctx.fillRect(3, iconY - 6, 5, 14);
} else {
// Play Icon (Triangle)
ctx.beginPath();
ctx.moveTo(-5, iconY - 8);
ctx.lineTo(9, iconY);
ctx.lineTo(-5, iconY + 8);
ctx.closePath();
ctx.fill();
}
ctx.restore();
},
drawWaves(ctx, W, H) {
const baseY = H * 0.82;
const dynamicAmp = H * 0.08;
// Reduced to 2 layers to save GPU/Bridge rendering time and ensure 60fps
const waves = [
{ color: 'rgba(52, 211, 153, 0.3)', speed: 1.5, freq: 0.012, amp: dynamicAmp * 0.7, offset: 0 },
{ color: 'rgba(16, 185, 129, 0.6)', speed: 2.5, freq: 0.015, amp: dynamicAmp, offset: Math.PI }
];
const activeAmp = this.waveAmplitudeMultiplier;
const t = this.time;
waves.forEach(wave => {
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, baseY);
// Increased step size to drastically reduce JS-to-Native bridge commands
const step = 25;
for (let x = 0; x <= W; x += step) {
// Removed edge envelope taper so waves crash cleanly into the edge of the screen
const y = baseY + Math.sin(x * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
ctx.lineTo(x, y);
}
// Add one final line to exactly W to ensure flush edge
const finalY = baseY + Math.sin(W * wave.freq + t * wave.speed + wave.offset) * wave.amp * activeAmp;
ctx.lineTo(W, finalY);
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = wave.color;
ctx.fill();
});
}
}
};
</script>
<style scoped>
.walker-canvas-container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.walker-canvas {
display: block;
width: 100%;
height: 100%;
background: transparent; /* Pure transparent to blend into page */
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,551 @@
<template>
<view class="page" :style="rootStyle">
<!-- ===== 统一运动律动视区 (Canvas 律动舞台) ===== -->
<view class="stage-canvas-wrapper">
<WalkerCanvas
ref="walkerCanvasRef"
:bpm="currentBpm"
:is-playing="isPlaying"
@toggle-play="onCenterTap"
/>
</view>
<!-- ===== 音色选择 ===== -->
<view class="sound-selector">
<view
class="sound-option"
:class="{ active: currentSound === 'crisp' }"
@click="onSoundSelect('crisp')"
>
<text class="sound-icon"></text>
<text class="sound-label">清脆</text>
</view>
<view
class="sound-option"
:class="{ active: currentSound === 'wood' }"
@click="onSoundSelect('wood')"
>
<text class="sound-icon">🪵</text>
<text class="sound-label">木鱼</text>
</view>
</view>
<!-- ===== 推荐档位(3 1) ===== -->
<view class="presets">
<view
v-for="p in LOOP_PRESETS"
:key="p.id"
class="preset"
:class="{ active: currentLoop === p.id }"
@click="onPresetTap(p.id)"
>
<text class="preset-name">{{ p.label }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ p.desc }}</text>
</view>
</view>
<!-- ===== 自定义节奏区域(含标题栏+折叠内容) ===== -->
<view class="custom-section">
<!-- 标题栏(始终显示) -->
<view class="custom-header" @click="onToggleCustom">
<text class="custom-title">{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}</text>
</view>
<!-- 折叠内容(仅展开时显示) -->
<view v-if="customExpanded" class="custom-content">
<!-- BPM 微调 -->
<view class="row">
<text class="row-label">BPM</text>
<view class="bpm-stepper">
<view class="step-btn" @click="onBpmDelta(-5)">5</view>
<view class="step-btn" @click="onBpmDelta(-1)">1</view>
<view class="step-val">{{ currentBpm }}</view>
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
</view>
</view>
<!-- 拍号 -->
<view class="row">
<text class="row-label">拍号</text>
<view class="meter-tabs">
<view
v-for="n in [2, 3, 4]"
:key="n"
class="meter-tab"
:class="{ active: customAccent === n }"
@click="onAccentSelect(n)"
>{{ n }}/4</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
import { useMetronome } from '../hooks/useMetronome'
import WalkerCanvas from './components/walker-canvas.vue'
type LoopId = 'slow' | 'normal' | 'brisk'
interface LoopPreset {
id: LoopId
bpm: number
label: string
desc: string
}
const LOOP_PRESETS: readonly LoopPreset[] = [
{ id: 'slow', bpm: 110, label: '慢走', desc: '热身 · 恢复' },
{ id: 'normal', bpm: 130, label: '健走', desc: '日常 · 通勤' },
{ id: 'brisk', bpm: 150, label: '快走', desc: '提速 · 燃脂' },
]
const customExpanded = ref<boolean>(false)
const currentLoop = ref<LoopId | null>('normal')
const currentSound = ref<'crisp' | 'wood'>('crisp')
const walkerCanvasRef = ref<any>(null)
/* 节拍器音效配置 */
const SOUND_PRESETS = {
crisp: {
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539371ebe13672.mp3',
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260527/202605271539376ff628472.mp3',
},
wood: {
normal: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
accent: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3',
},
}
//
const fg = useMetronome({
initialBpm: 130,
accentEvery: 2,
clickSrc: SOUND_PRESETS.crisp.normal,
accentSrc: SOUND_PRESETS.crisp.accent,
poolSize: 4,
//
onBeat: (index, isAccent) => {
if (walkerCanvasRef.value && walkerCanvasRef.value.triggerBeat) {
walkerCanvasRef.value.triggerBeat()
}
}
})
const isPlaying = computed(() => fg.isPlaying.value)
const currentBpm = computed(() => fg.bpm.value)
const customAccent = computed(() => fg.accentEvery.value)
const intervalMs = computed(() => 60000 / fg.bpm.value)
/* ============================================================
* 中央圆按钮
* ============================================================ */
const onCenterTap = () => {
if (fg.isPlaying.value) {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
/* ============================================================
* 档位卡片
* ============================================================ */
const onPresetTap = (id: LoopId) => {
const preset = LOOP_PRESETS.find(p => p.id === id)
if (!preset) return
currentLoop.value = id
fg.setBpm(preset.bpm)
if (!fg.isPlaying.value) {
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
/* ============================================================
* 自定义折叠区切换
* ============================================================ */
const onToggleCustom = () => {
customExpanded.value = !customExpanded.value
if (customExpanded.value) {
fg.preload()
} else {
// BPM
const matched = LOOP_PRESETS.find(p => p.bpm === fg.bpm.value)
currentLoop.value = matched ? matched.id : null
}
}
const onBpmDelta = (delta: number) => {
fg.setBpm(fg.bpm.value + delta)
currentLoop.value = null //
}
const onAccentSelect = (n: number) => {
fg.setAccentEvery(n)
}
const onSoundSelect = (sound: 'crisp' | 'wood') => {
currentSound.value = sound
const preset = SOUND_PRESETS[sound]
fg.updateAudioSrc(preset.normal, preset.accent)
}
const rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
onShow(() => {
fg.preload()
})
function cleanupMetronome() {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
onHide(cleanupMetronome)
onUnload(cleanupMetronome)
onUnmounted(cleanupMetronome)
</script>
<style lang="scss" scoped>
/* ============================================================
* 设计 token 浅色清爽
* ============================================================ */
$bg-color: #f8fafc;
$card-bg: #ffffff;
$card-border: rgba(15, 23, 42, 0.06);
$text-1: #0f172a;
$text-2: #475569;
$text-3: #94a3b8;
$brand: #10b981;
$brand-soft: #d1fae5;
$brand-deep: #047857;
$warn: #f59e0b;
$warn-soft: #fef3c7;
$radius-card: 24rpx;
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
/* ============================================================
* 页面容器
* ============================================================ */
.page {
position: relative;
min-height: 100vh;
box-sizing: border-box;
padding: 30rpx 28rpx 50rpx;
background: $bg-color;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
color: $text-1;
}
/* ============================================================
* 统一运动律动视区 (Canvas)
* ============================================================ */
.stage-canvas-wrapper {
width: 100vw;
margin-left: -28rpx;
margin-right: -28rpx;
flex-shrink: 0; /* 防止被压缩 */
height: 600rpx; /* 固定高度,不随内容变化 */
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 24rpx;
position: relative;
}
/* ============================================================
* 音色选择器
* ============================================================ */
.sound-selector {
width: 100%;
display: flex;
gap: 14rpx;
margin-bottom: 20rpx;
}
.sound-option {
flex: 1;
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 20rpx 16rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.sound-icon {
font-size: 32rpx;
}
.sound-label {
font-size: 26rpx;
font-weight: 600;
color: $text-2;
letter-spacing: 1rpx;
}
&:active {
transform: scale(0.97);
}
&.active {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
border-color: $brand;
box-shadow:
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
.sound-label {
color: $brand-deep;
}
}
}
/* ============================================================
* 三档位推荐
* ============================================================ */
.presets {
width: 100%;
display: flex;
gap: 14rpx;
}
.preset {
flex: 1;
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 26rpx 8rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.preset-name {
font-size: 28rpx;
font-weight: 700;
color: $text-1;
letter-spacing: 2rpx;
}
.preset-bpm {
font-size: 22rpx;
color: $text-2;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.preset-desc {
font-size: 18rpx;
color: $text-3;
letter-spacing: 1rpx;
margin-top: 2rpx;
}
&:active {
transform: scale(0.97);
}
&.active {
background: $brand-soft;
border-color: $brand;
box-shadow:
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
.preset-name {
color: $brand-deep;
}
.preset-bpm {
color: $brand-deep;
}
.preset-desc {
color: $brand;
}
}
}
/* ============================================================
* 自定义节奏区域(标题栏 + 折叠内容)
* ============================================================ */
.custom-section {
width: 100%;
margin-top: 12rpx;
}
.custom-header {
width: 100%;
padding: 16rpx 6rpx;
display: flex;
align-items: center;
justify-content: center;
.custom-title {
font-size: 22rpx;
color: $text-3;
letter-spacing: 0.5rpx;
&:active {
color: $text-2;
}
}
}
.custom-content {
width: 100%;
background: $card-bg;
border: 2rpx solid rgba(245, 158, 11, 0.25);
border-radius: $radius-card;
box-shadow: 0 6rpx 18rpx rgba(245, 158, 11, 0.08);
padding: 20rpx 24rpx 22rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 8rpx;
}
.custom-warn {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 4rpx;
.warn-dot {
font-size: 18rpx;
color: $warn;
}
.warn-text {
font-size: 22rpx;
color: #b45309;
letter-spacing: 0.5rpx;
font-weight: 600;
}
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
.row-label {
font-size: 24rpx;
color: $text-2;
font-weight: 600;
letter-spacing: 1rpx;
flex-shrink: 0;
width: 70rpx;
}
}
.bpm-stepper {
display: flex;
align-items: center;
gap: 6rpx;
flex: 1;
justify-content: flex-end;
.step-btn {
min-width: 56rpx;
height: 52rpx;
line-height: 52rpx;
text-align: center;
font-size: 22rpx;
color: $text-2;
background: #f1f5f9;
border-radius: 10rpx;
font-weight: 600;
padding: 0 10rpx;
font-variant-numeric: tabular-nums;
&:active {
background: #e2e8f0;
transform: scale(0.94);
}
}
.step-val {
min-width: 84rpx;
height: 52rpx;
line-height: 52rpx;
text-align: center;
font-size: 30rpx;
font-weight: 800;
color: $text-1;
font-variant-numeric: tabular-nums;
background: $warn-soft;
border-radius: 10rpx;
}
}
.meter-tabs {
display: flex;
gap: 8rpx;
.meter-tab {
height: 52rpx;
line-height: 52rpx;
padding: 0 18rpx;
font-size: 22rpx;
color: $text-2;
background: #f1f5f9;
border-radius: 10rpx;
font-weight: 600;
letter-spacing: 1rpx;
&.active {
background: $warn;
color: #fff;
box-shadow: 0 4rpx 10rpx rgba(245, 158, 11, 0.3);
}
&:active {
transform: scale(0.94);
}
}
}
/* ============================================================
* 底部行(已废弃,保留样式以防引用)
* ============================================================ */
.bottom-row {
width: 100%;
margin-top: 6rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6rpx;
}
.bottom-link {
font-size: 22rpx;
color: $text-3;
letter-spacing: 0.5rpx;
padding: 8rpx 4rpx;
&:active {
color: $text-2;
}
}
.bottom-hint {
font-size: 22rpx;
color: $text-3;
letter-spacing: 0.5rpx;
}
</style>
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
# 训练模块静态资源
本目录用于存放"练一练"功能的所有音频素材。
## 目录结构
```
training/static/
├── audio/ # 当前为空 —— 节拍器音频已全部迁移到 COS CDN
├── voice/ # (规划中) TTS 语音教练
│ ├── numbers/ 数字报数 1~30
│ └── prompts/ 开始/休息/再来 等口令
├── bgm/ # (规划中) 训练/休息背景乐
└── footprint.svg # 脚印图标(早期方案残留,可保留作为备用素材)
```
> 节拍器音频已完全走 CDN`gz-1349751149.cos.ap-guangzhou.myqcloud.com`),减少小程序包体积约 210 KB。`useMetronomeBg.ts` / `useMetronome.ts` 中的 URL 即源信息,更换音色只需改 hook 里的常量。
## 准备步骤
### 1. 语音素材(小米 MiMo TTS 自动生成)
需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key
- 文档: https://platform.xiaomimimo.com/
- API Key 形如 `sk-xxxxxxxx`
```bash
export MIMO_API_KEY=sk-your-key-here
cd uniapp
node scripts/generate-voice.mjs
```
会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
**可选参数**
```bash
# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean
node scripts/generate-voice.mjs --voice 茉莉
# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
node scripts/generate-voice.mjs --format wav
# 强制重新生成(默认存在则跳过)
node scripts/generate-voice.mjs --force
```
**音色推荐**(中文女声更适合健身教练):
- `冰糖`:温柔甜美,亲和力强(默认)
- `茉莉`:清爽利落,有"运动博主"感
- `苏打`:年轻男声,有力量感
- `白桦`:成熟男声,沉稳
### 2. 节拍器 click / 循环音轨
**全部托管在腾讯云 COS2026-05-26 上传)**,本地不再保留:
| 用途 | 文件 | 大小 | 引擎 |
|---|---|---|---|
| 单拍 click(基础) | `click.mp3` | 3.4 KB | InnerAudio |
| 单拍 click(木鱼,推荐) | `click-wood.mp3` | 4.6 KB | InnerAudio (custom 模式) |
| 循环音轨 慢走 80 BPM | `loop_80bpm_2.mp3` | 71 KB | BgAudio (preset) |
| 循环音轨 健走 110 BPM | `loop_110bpm_2.mp3` | 65 KB | BgAudio (preset) |
| 循环音轨 快走 130 BPM | `loop_130bpm_2.mp3` | 66 KB | BgAudio (preset) |
> 微信 `BackgroundAudioManager.src` 只接受 https URL,不支持包内资源,所以必须走 CDN。
> COS 域名已加入小程序后台 `downloadFile 合法域名`,跑步视频也走同一域名。
更换音色:去 [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 找新素材 → 上传到 COS → 在 `useMetronomeBg.ts` / `useMetronome.ts` 里替换 URL 即可。
### 3. 背景音乐
每首 30 秒~2 分钟即可(loop 后听不出接缝)。
推荐来源(免费可商用):
- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
- [freemusicarchive.org](https://freemusicarchive.org)
- [bensound.com](https://bensound.com)(含署名)
文件大小建议 < 1MBmp3 128kbps 单声道即可)。
## 在代码里被引用的位置
- `training/hooks/useMetronome.ts``DEFAULT_CLICK_SRC` (CDN: click.mp3)
- `training/hooks/useMetronomeBg.ts``LOOP_PRESETS[*].src` (CDN: loop_*.mp3)
- `training/pages/metronome.vue``CLICK_WOOD_URL` (CDN: click-wood.mp3, custom 模式)
- `training/hooks/useVoiceCoach.ts``voice/numbers/*.mp3``voice/prompts/*.mp3` (规划中)
- `training/hooks/useTrainingBgm.ts``bgm/*.mp3` (规划中)
如需修改路径,修改对应 hook 文件里的常量即可。
## 注意事项
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
---
## 后台/锁屏播放设计备忘(未来健身模块用)
> 节拍器目前用 `InnerAudioContext` + `requiredBackgroundModes:["audio"]` 已能覆盖 5~10 分钟健走场景。
> 真要做长时间训练 BGM、锁屏控制条等高级能力,参考下面的 `BackgroundAudioManager` 方案。
### 引擎选择对照表
健身模块的音频天然分两类,配两套引擎不冲突:
| 音频类型 | 时长 | 推荐引擎 | 理由 |
|---|---|---|---|
| 训练/休息 BGM | 30s~2min 循环 | **BackgroundAudioManager** | 长流、需要后台/锁屏不停 |
| 语音教练("开始/休息/再来" | 1~3s 单句 | InnerAudioContext | 短促,前台用即可 |
| 报数(1, 2, 3... | 0.5~1s | InnerAudioContext + 池子 | 高频短促 |
| 节拍器 click | 50~150ms | InnerAudioContext + 池子 | 极短,BgAudio 不接受 |
**关键约束:BgAudio 是全局单例,一次只能播 1 个音频**。所以让它专门播 BGM 这类"长流",其他短音用 InnerAudio 配合,互不打架。
### BackgroundAudioManager 限制清单(踩坑预警)
1. **全局单例**:整个小程序同一时刻只能播一个,多场景要协调切换
2. **音频时长 ≥ 1 秒**:太短的 click 会被微信判定异常忽略
3. **必填 metadata**`title` / `coverImgUrl` / `singer` / `epname` / `webUrl` 缺一会报错
4. **切 src 有延迟**:200~500ms 初始化抖动,频繁切换会卡顿
5. **必须声明 `requiredBackgroundModes:["audio"]`** 才能后台播放
6. **iOS 锁屏豁免**:声明后能锁屏继续播,无 5 分钟时长限制(vs InnerAudio 的 5min
7. **会显示系统控制条**:锁屏/通知栏出现带封面+暂停按钮的控制条
### 推荐架构(健身模块上线时)
```
training/hooks/
├── useTrainingBgm.ts → BackgroundAudioManager (后台/锁屏继续放音乐)
├── useVoiceCoach.ts → InnerAudioContext (语音指导,前台用)
└── useMetronome.ts → InnerAudioContext (节拍器,池子方案,保持现状)
```
页面 `pages.json` 声明:
```json
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "练一练",
"requiredBackgroundModes": ["audio"]
}
}
```
锁屏会显示 BGM 控制条,老人能直接在锁屏点暂停。训练页面通过 `bgm.onPause()` 监听同步暂停训练,体验连贯。
### 必备资产清单
到时候要准备的文件:
- **BGM 2 首**`bgm/train-light.mp3`(训练)、`bgm/rest-meditation.mp3`(休息)
- 每个 30s~1min128kbps 单声道,30~80KB
- 推荐来源:[pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
- **锁屏封面**`audio/cover.jpg` 200×200
- 简洁绿色背景 + 训练 emoji 即可
### BackgroundAudioManager API 速查
```ts
const bgm = uni.getBackgroundAudioManager()
/* 必填 metadata,缺一会报错 */
bgm.title = '健走训练中'
bgm.coverImgUrl = '/training/static/audio/cover.jpg'
bgm.epname = '甄养堂'
bgm.singer = '健走节拍'
bgm.webUrl = '' // 必填,空字符串可
/* src 一旦赋值会自动播放 */
bgm.src = '/training/static/audio/bgm/train-light.mp3'
/* 控制 */
bgm.pause() // 暂停 (锁屏控制条仍在)
bgm.play() // 继续
bgm.stop() // 真停 + 隐藏控制条
bgm.seek(30) // 跳到 30s
/* 事件监听 — 用户从锁屏点暂停时会触发 */
bgm.onPause(() => { /* 同步训练 UI 状态 */ })
bgm.onPlay(() => {})
bgm.onStop(() => {})
bgm.onEnded(() => { /* 不 loop 时触发,可手动接下一首 */ })
bgm.onError((err) => { console.error(err) })
```
### 切换不同场景音乐的模式
```ts
function switchBgm(scene: 'training' | 'resting' | 'none') {
if (scene === 'none') {
bgm.stop()
return
}
bgm.title = scene === 'training' ? '健走训练中' : '休息恢复中'
bgm.src = scene === 'training'
? '/training/static/audio/bgm/train-light.mp3'
: '/training/static/audio/bgm/rest-meditation.mp3'
/* 注:setSrc 会自动播放,有 200~500ms 切换延迟 */
}
```
### 节拍器要不要也升级到 BgAudio?
**目前不需要**。节拍器升级 BgAudio 的代价:
- 需要预合成 3 个档位的循环 mp380/110/130 BPM × 2 拍)
- 必须砍掉右下角微调按钮(预合成 mp3 改不了 BPM)
- 切档位有 200~500ms 卡顿
如果未来发现"健走 30 分钟以上锁屏会停"才考虑改。当前 5~10 分钟场景 InnerAudio + `requiredBackgroundModes` 够用。
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150">
<g fill="#e2e8f0" fill-opacity="0.92">
<path d="M 50,30 C 38,30 28,32 24,40 C 19,50 18,60 22,70 C 26,82 26,92 28,102 C 30,122 40,140 50,140 C 60,140 70,122 72,102 C 74,92 74,82 78,70 C 82,60 81,50 76,40 C 72,32 62,30 50,30 Z"/>
<ellipse cx="30" cy="20" rx="6.5" ry="8" transform="rotate(-18 30 20)"/>
<ellipse cx="43" cy="11" rx="5" ry="6.8" transform="rotate(-6 43 11)"/>
<ellipse cx="55" cy="9" rx="4.5" ry="6.2" transform="rotate(4 55 9)"/>
<ellipse cx="66" cy="13" rx="4" ry="5.5" transform="rotate(13 66 13)"/>
<ellipse cx="76" cy="20" rx="3.5" ry="5" transform="rotate(22 76 20)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 730 B

@@ -0,0 +1,276 @@
# 握力环训练音效采集清单
> 更新日期: 2026-05-28
> 用途: 为握力环训练页面添加完整音效体系
---
## 📋 总体规划
| 类型 | 数量 | 单文件大小 | 总大小预算 | 优先级 |
|------|------|-----------|-----------|--------|
| 节拍音效 | 1-3 个音色 | 5-10 KB | < 30 KB | P0 |
| 捏碎音效 | 4 个 | 20-50 KB | < 200 KB | P0 |
| 背景音乐 | 1-2 首 | 200-500 KB | < 1 MB | P1 |
| 语音鼓励 | 4-8 句 | 10-30 KB | < 200 KB | P1 |
**总计预算**: < 1.5 MB(都放 CDN,不影响小程序包体积)
---
## 🥁 一、节拍音效 (P0 - 必需)
### 用途
训练时按 BPM 节奏播放,引导用户握紧/松开。
### 推荐音色 (任选 1-3 个)
| 音色 | 推荐度 | 描述 | Pixabay 搜索词 |
|------|--------|------|---------------|
| 木鱼 | ⭐⭐⭐⭐⭐ | 温暖、东方意境,适合养生主题 | `wood block`, `wooden tap`, `muyu` |
| 心跳 | ⭐⭐⭐⭐ | 有运动感,代入感强 | `heart beat`, `heartbeat single` |
| 轻柔敲击 | ⭐⭐⭐⭐ | 不刺耳,适合长时间训练 | `soft tap`, `gentle knock` |
| 竹片声 | ⭐⭐⭐ | 清脆,有节奏感 | `bamboo tap`, `bamboo click` |
| 鼓点 | ⭐⭐⭐ | 有力量感,适合"重度"档位 | `kick drum soft`, `tom drum tap` |
### 技术要求
- **格式**: mp3
- **时长**: 50-150ms (不能太长,会重叠)
- **采样率**: 22050Hz 或 44100Hz
- **声道**: 单声道
- **大小**: < 10 KB
### 命名规范
```
grip-tick-wood.mp3 # 木鱼
grip-tick-heart.mp3 # 心跳
grip-tick-soft.mp3 # 轻柔
grip-tick-bamboo.mp3 # 竹片
```
---
## 💥 二、捏碎音效 (P0 - 必需)
### 用途
达成奖励里程碑时播放,与粒子特效同步。
### 音效清单
| 奖励 | 触发次数 | Pixabay 搜索词 | 时长 | 备注 |
|------|---------|---------------|------|------|
| 🥚 鸡蛋 | 8-12 | `egg crack`, `eggshell break`, `crack sound` | 0.5-1s | 清脆短促 |
| 🥜 核桃 | 15-20 | `nut crack`, `walnut break`, `wood snap` | 0.5-1s | 木质爆裂感 |
| 🥫 易拉罐 | 25-30 | `can crush`, `aluminum crush`, `metal crunch` | 1-2s | 金属挤压 |
| 🎈 气球 | 40-50 | `balloon pop`, `pop burst`, `balloon explosion` | 0.3-0.5s | 短促爆裂 |
### 技术要求
- **格式**: mp3
- **时长**: 0.3-2s
- **大小**: < 50 KB
- **音量**: 适中(避免突然吓到用户)
### 命名规范
```
crush-egg.mp3
crush-walnut.mp3
crush-can.mp3
crush-balloon.mp3
```
### 试听建议
下载前一定要试听,优先选择:
- ✅ 干净,无杂音
- ✅ 中等音量(峰值不爆音)
- ✅ 短促有力(不拖沓)
- ❌ 避免: 太长、太尖锐、有人声
---
## 🎵 三、背景音乐 BGM (P1 - 推荐)
### 用途
训练时持续播放,营造轻松氛围,缓解握力训练的枯燥感。
### 风格推荐
| 风格 | 描述 | Pixabay 搜索词 | 适合人群 |
|------|------|---------------|---------|
| **Lo-Fi** | 慵懒电子,流行选择 | `lofi calm`, `lofi study`, `lofi chill` | 年轻人 |
| **冥想轻音乐** | 钢琴 + 自然音,放松 | `meditation`, `calm piano`, `relaxing` | 中老年 |
| **轻爵士** | 优雅放松,有质感 | `light jazz`, `cafe jazz`, `bossa nova` | 通用 |
| **自然环境音** | 鸟叫/流水/雨声 | `nature ambient`, `forest sounds`, `rain bgm` | 喜欢自然的用户 |
| **东方禅意** | 古筝/笛子,养生 | `chinese zen`, `oriental calm`, `guqin` | 养生主题契合 |
### 推荐场景搭配
- **轻度档位 (60 BPM)**: 冥想轻音乐 (节奏舒缓)
- **中度档位 (80 BPM)**: 轻爵士 / Lo-Fi (适中)
- **重度档位 (100 BPM)**: 轻快电子 / 健身流行
### 技术要求
- **格式**: mp3
- **时长**: 30s - 2min (循环播放)
- **比特率**: 128 kbps (单声道 64 kbps 也可)
- **大小**: < 500 KB
- **循环点**: 选择起止点接近的片段,避免明显接缝
### 命名规范
```
bgm-grip-light.mp3 # 轻度档位 BGM
bgm-grip-medium.mp3 # 中度档位 BGM (可选)
bgm-grip-heavy.mp3 # 重度档位 BGM (可选)
```
或简化:
```
bgm-grip-default.mp3 # 默认 BGM (所有档位通用)
```
---
## 🎙️ 四、语音鼓励 (P1 - 推荐)
### 用途
达成奖励时播放鼓励语音,增强成就感。
### 两种方案
#### 方案 A: 小米 MiMo TTS 自动生成 (推荐)
项目已有 `useVoiceCoach.ts` + `generate-voice.mjs` 脚本支持。
**操作步骤**:
1. 申请小米 MiMo API Key: https://platform.xiaomimimo.com/
2. 配置环境变量: `export MIMO_API_KEY=sk-xxx`
3. 在 `generate-voice.mjs` 添加握力环话术
4. 运行脚本自动生成
**话术清单**:
```javascript
const gripPrompts = [
{ id: 'grip-egg', text: '握力不错!' },
{ id: 'grip-walnut', text: '力量惊人!' },
{ id: 'grip-can', text: '太强了!' },
{ id: 'grip-balloon', text: '完美!继续保持!' },
{ id: 'grip-start', text: '开始训练!' },
{ id: 'grip-pause', text: '休息一下!' },
{ id: 'grip-encourage-1', text: '加油,再来!' },
{ id: 'grip-encourage-2', text: '坚持住!' },
]
```
**音色推荐**: `茉莉` (清爽利落,有"运动博主"感)
#### 方案 B: 手工录制
如果不想用 TTS,可以自己录制(质感更好,但费时):
- 工具: 手机录音 / Audacity
- 环境: 安静的房间,距离麦克风 15-30cm
- 后期: 去噪 + 标准化音量
---
## 🛠️ 五、采集工具推荐
### 在线裁剪
- [audiotrimmer.com](https://audiotrimmer.com) - 简单的 mp3 裁剪
- [mp3cut.net](https://mp3cut.net) - 功能更全
### 桌面工具(免费)
- **Audacity** (Mac/Windows/Linux) - 专业级,可以剪辑、降噪、标准化
- **Logic Pro** (Mac, 付费) - 更专业的音频处理
### 音量标准化
统一音量(避免有的太响有的太轻):
```bash
# 使用 ffmpeg (Mac: brew install ffmpeg)
ffmpeg -i input.mp3 -af "loudnorm=I=-16:LRA=11:TP=-1.5" output.mp3
```
或在 Audacity 里: Effect → Normalize (峰值 -3dB)
---
## ☁️ 六、上传到 CDN
### 项目使用的 COS 信息
- **域名**: `gz-1349751149.cos.ap-guangzhou.myqcloud.com`
- **目录建议**: `uploads/training/grip-ring/`
### 上传途径
1. **腾讯云控制台**: https://console.cloud.tencent.com/cos
2. **管理后台** (如果项目有上传入口)
3. **找团队 COS 管理员上传**
### 上传后获得的 URL 格式
```
https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/training/grip-ring/crush-egg.mp3
```
---
## 📝 七、最终采集清单 (一次性收齐)
### 最小可行版本 (MVP) - 4 个文件
```
☐ grip-tick-wood.mp3 # 节拍音 - 木鱼
☐ crush-egg.mp3 # 鸡蛋
☐ crush-walnut.mp3 # 核桃
☐ crush-can.mp3 # 易拉罐
☐ crush-balloon.mp3 # 气球
```
### 完整版本 - 10 个文件
```
节拍音 (3 选 1 或全部):
☐ grip-tick-wood.mp3
☐ grip-tick-heart.mp3
☐ grip-tick-soft.mp3
捏碎音效 (必需 4 个):
☐ crush-egg.mp3
☐ crush-walnut.mp3
☐ crush-can.mp3
☐ crush-balloon.mp3
BGM (1-3 首):
☐ bgm-grip-default.mp3
☐ bgm-grip-meditation.mp3 (可选)
☐ bgm-grip-lofi.mp3 (可选)
语音鼓励 (TTS 自动生成,8 个):
☐ grip-egg.mp3
☐ grip-walnut.mp3
☐ grip-can.mp3
☐ grip-balloon.mp3
☐ grip-start.mp3
☐ grip-pause.mp3
☐ grip-encourage-1.mp3
☐ grip-encourage-2.mp3
```
---
## ✅ 八、采集完成后
把所有 URL 整理给我,我会:
1. 更新 `grip-ring.vue` 中的 REWARDS 配置 (替换捏碎音效 URL)
2. 创建/更新 `useMetronome.ts` 的音色配置 (支持切换节拍音色)
3. 在 `grip-ring.vue` 集成 `useTrainingBgm` (添加 BGM)
4. 在 `grip-ring.vue` 集成 `useVoiceCoach` (添加语音鼓励)
5. 在 UI 上添加音色/BGM 切换选项 (可选)
预计代码改动: 50-100 行,1-2 小时工作量。
---
## 📚 参考链接
- [Pixabay 音效](https://pixabay.com/sound-effects/) - 免费可商用
- [Pixabay 音乐](https://pixabay.com/music/) - 免费可商用
- [Freesound](https://freesound.org) - 大量音效,部分需署名
- [Mixkit](https://mixkit.co/free-sound-effects/) - 免费音效库
- [Bensound](https://bensound.com) - 免费音乐(需署名)
- [Free Music Archive](https://freemusicarchive.org) - 免费音乐
**版权提示**: 务必确认音效/音乐是"免费商用"(Public Domain 或 CC0),避免侵权风险。
+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/**',
],
},
},
})
+29
View File
@@ -0,0 +1,29 @@
import request from '@/utils/request'
// 账号管理 API
export function apiAssetUserList(params: any) {
return request.get({ url: '/asset.AssetUser/lists', params })
}
export function apiAssetUserAdd(params: any) {
return request.post({ url: '/asset.AssetUser/add', params })
}
export function apiAssetUserEdit(params: any) {
return request.post({ url: '/asset.AssetUser/edit', params })
}
export function apiAssetUserDelete(params: any) {
return request.post({ url: '/asset.AssetUser/delete', params })
}
// 资源管理 API
export function apiAssetResourceList(params: any) {
return request.get({ url: '/asset.AssetResource/lists', params })
}
export function apiAssetResourceAdd(params: any) {
return request.post({ url: '/asset.AssetResource/add', params })
}
export function apiAssetResourceEdit(params: any) {
return request.post({ url: '/asset.AssetResource/edit', params })
}
export function apiAssetResourceDelete(params: any) {
return request.post({ url: '/asset.AssetResource/delete', params })
}
+50
View File
@@ -0,0 +1,50 @@
import request from '@/utils/request'
export function getSelfInputOverview(params: any) {
return request.get({ url: '/stats.self_input/overview', params })
}
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
export function getSelfInputMediaSourceOptions() {
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
}
export function personalYejiLists(params: any) {
return request.get({ url: '/stats.personal_yeji/lists', params })
}
export function personalYejiAdd(data: any) {
return request.post({ url: '/stats.personal_yeji/add', data })
}
export function personalYejiEdit(data: any) {
return request.post({ url: '/stats.personal_yeji/edit', data })
}
export function personalYejiDetail(params: any) {
return request.get({ url: '/stats.personal_yeji/detail', params })
}
export function personalYejiDelete(params: any) {
return request.post({ url: '/stats.personal_yeji/delete', params })
}
export function personalAccountCostLists(params: any) {
return request.get({ url: '/stats.personal_account_cost/lists', params })
}
export function personalAccountCostAdd(data: any) {
return request.post({ url: '/stats.personal_account_cost/add', data })
}
export function personalAccountCostEdit(data: any) {
return request.post({ url: '/stats.personal_account_cost/edit', data })
}
export function personalAccountCostDetail(params: any) {
return request.get({ url: '/stats.personal_account_cost/detail', params })
}
export function personalAccountCostDelete(params: any) {
return request.post({ url: '/stats.personal_account_cost/delete', params })
}
+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 })
}
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -102,8 +102,8 @@ export default defineComponent({
const visible = ref(false)
const fileList = ref<any[]>([])
// video + direct http-request
const useDirect = computed(() => props.direct && props.type === 'video')
// video/voice + direct http-request
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
const handleProgress = () => {
visible.value = true
@@ -151,6 +151,8 @@ export default defineComponent({
return '.jpg,.png,.gif,.jpeg,.ico'
case 'video':
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
case 'voice':
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
default:
return '*'
}
@@ -162,7 +164,7 @@ export default defineComponent({
try {
const data = await uploadVideoDirectToCos({
file: options.file,
type: 'video',
type: props.type as any,
cid: Number((options.data as any)?.cid ?? 0),
onProgress(info) {
// ElUpload
+291
View File
@@ -0,0 +1,291 @@
<template>
<div class="asset-resource-container">
<!-- 搜索区域 -->
<el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="标题">
<el-input
placeholder="请输入标题关键词"
v-model="searchData.title"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="上传时间">
<el-date-picker
v-model="searchData.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
style="width: 280px;"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 列表区域 -->
<el-card class="!border-none mt-4" shadow="never">
<div class="mb-4">
<el-button type="primary" @click="handleAdd">上传资源</el-button>
</div>
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
<el-tab-pane label="图片" name="1"></el-tab-pane>
<el-tab-pane label="视频" name="2"></el-tab-pane>
<el-tab-pane label="语音" name="3"></el-tab-pane>
</el-tabs>
<el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="title" label="标题" min-width="80" />
<el-table-column label="预览" width="100">
<template #default="{ row }">
<el-image v-if="row.type == 1" :src="row.file_url" style="width: 50px; height: 50px;" :preview-src-list="[row.file_url]" preview-teleported />
<el-link v-else :href="row.file_url" target="_blank" type="primary">点击预览</el-link>
</template>
</el-table-column>
<el-table-column label="关联账号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="!row.users?.length" type="info" size="small">公开资源</el-tag>
<span v-else>{{ row.users?.map((u: any) => u.phone).join(', ') }}</span>
</template>
</el-table-column>
<el-table-column prop="create_time" label="上传时间" width="180" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getList"
@current-change="getList"
/>
</div>
</el-card>
<!-- 新增/编辑资源弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="computedRules" label-width="100px">
<el-form-item label="资源类型" prop="type" v-if="!isEdit">
<el-radio-group v-model="formData.type">
<el-radio :label="1">图片</el-radio>
<el-radio :label="2">视频</el-radio>
<el-radio :label="3">语音</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入资源标题" />
</el-form-item>
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
<material-picker v-if="formData.type === 1" v-model="formData.file_url" :limit="1" type="image" />
<upload v-else-if="formData.type === 2" type="video" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
<el-button type="primary">上传视频 (OSS直传)</el-button>
</upload>
<upload v-else-if="formData.type === 3" type="voice" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
<el-button type="primary">上传语音 (OSS直传)</el-button>
</upload>
<div v-if="formData.file_url && formData.type !== 1" class="ml-4 text-primary truncate max-w-xs" :title="formData.file_url">
已上传: {{ formData.file_url }}
</div>
</el-form-item>
<el-form-item label="关联账号" prop="user_ids">
<el-select v-model="formData.user_ids" multiple filterable placeholder="不选择则为公开资源" clearable style="width: 100%;">
<el-option v-for="item in userOptions" :key="item.id" :label="item.phone" :value="item.id" />
</el-select>
<div style="font-size: 12px; color: #909399; margin-top: 4px;">不选择账号则资源为公开资源所有人可见</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">{{ isEdit ? '保存修改' : '确定发布' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiAssetResourceList, apiAssetResourceAdd, apiAssetResourceEdit, apiAssetResourceDelete, apiAssetUserList } from '@/api/asset'
import MaterialPicker from '@/components/material/picker.vue'
import Upload from '@/components/upload/index.vue'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const searchData = reactive<any>({
title: '',
dateRange: []
})
const queryParams = reactive<any>({
type: '1',
page_no: 1,
page_size: 15
})
const dialogVisible = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const isEdit = ref(false)
const editId = ref(0)
const userOptions = ref<any[]>([])
const formData = reactive<any>({
type: 1,
title: '',
file_url: '',
cover_url: '',
user_ids: []
})
const computedRules = computed(() => {
if (isEdit.value) {
return {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
}
}
return {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
file_url: [{ required: true, message: '请输入或上传文件获取链接', trigger: 'blur' }]
}
})
const buildQueryParams = () => {
const params: any = { ...queryParams }
if (searchData.title) {
params.title = searchData.title
}
if (searchData.dateRange && searchData.dateRange.length === 2) {
params.start_time = searchData.dateRange[0]
params.end_time = searchData.dateRange[1]
}
return params
}
const getList = async () => {
loading.value = true
try {
const res = await apiAssetResourceList(buildQueryParams())
tableData.value = res.lists
total.value = res.count
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const fetchUserOptions = async () => {
try {
const res = await apiAssetUserList({ page_no: 1, page_size: 9999 })
userOptions.value = res.lists
} catch (e) {
console.error(e)
}
}
const handleSearch = () => {
queryParams.page_no = 1
getList()
}
const handleReset = () => {
searchData.title = ''
searchData.dateRange = []
queryParams.page_no = 1
getList()
}
const handleTabChange = () => {
queryParams.page_no = 1
getList()
}
const handleAdd = () => {
isEdit.value = false
editId.value = 0
Object.assign(formData, { type: parseInt(queryParams.type), title: '', file_url: '', cover_url: '', user_ids: [] })
fetchUserOptions()
dialogVisible.value = true
}
const handleEdit = (row: any) => {
isEdit.value = true
editId.value = row.id
Object.assign(formData, {
type: row.type,
title: row.title,
file_url: row.file_url,
cover_url: row.cover_url || '',
user_ids: row.users?.map((u: any) => u.id) || []
})
fetchUserOptions()
dialogVisible.value = true
}
const handleUploadSuccess = (response: any) => {
formData.file_url = response?.data?.uri || response?.data?.url || ''
ElMessage.success('上传成功')
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该资源吗?用户将无法再看到。', '提示', { type: 'warning' })
await apiAssetResourceDelete({ id: row.id })
ElMessage.success('删除成功')
getList()
} catch (e) {
// canceled
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitLoading.value = true
try {
if (isEdit.value) {
await apiAssetResourceEdit({
id: editId.value,
title: formData.title,
user_ids: formData.user_ids
})
ElMessage.success('编辑成功')
} else {
await apiAssetResourceAdd(formData)
ElMessage.success('上传分发成功')
}
dialogVisible.value = false
getList()
} catch (e) {
console.error(e)
} finally {
submitLoading.value = false
}
}
})
}
onMounted(() => {
getList()
})
</script>
+204
View File
@@ -0,0 +1,204 @@
<template>
<div class="asset-user-container">
<!-- 搜索区域 -->
<el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="手机号">
<el-input
placeholder="请输入手机号"
v-model="searchData.phone"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 列表区域 -->
<el-card class="!border-none mt-4" shadow="never">
<div class="mb-4">
<el-button type="primary" @click="handleAdd">新增账号</el-button>
</div>
<el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="phone" label="手机号" />
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
active-text="正常"
inactive-text="禁用"
inline-prompt
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getList"
@current-change="getList"
/>
</div>
</el-card>
<!-- 编辑/新增弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号(作为登录账号)" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const searchData = reactive({
phone: ''
})
const queryParams = reactive<any>({
page_no: 1,
page_size: 15
})
const dialogVisible = ref(false)
const dialogTitle = ref('')
const submitLoading = ref(false)
const formRef = ref()
const formData = reactive({
id: '',
phone: '',
password: '',
status: 1
})
const rules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
const getList = async () => {
loading.value = true
try {
const params = { ...queryParams, ...searchData }
const res = await apiAssetUserList(params)
tableData.value = res.lists
total.value = res.count
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page_no = 1
getList()
}
const handleReset = () => {
searchData.phone = ''
queryParams.page_no = 1
getList()
}
const handleStatusChange = async (row: any) => {
try {
await apiAssetUserEdit({ id: row.id, status: row.status })
ElMessage.success(row.status === 1 ? '已启用' : '已禁用')
} catch (e) {
//
row.status = row.status === 1 ? 0 : 1
console.error(e)
}
}
const handleAdd = () => {
dialogTitle.value = '新增账号'
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
dialogVisible.value = true
}
const handleEdit = (row: any) => {
dialogTitle.value = '编辑账号'
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
dialogVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
await apiAssetUserDelete({ id: row.id })
ElMessage.success('删除成功')
getList()
} catch (e) {
// canceled
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitLoading.value = true
try {
if (formData.id) {
await apiAssetUserEdit(formData)
} else {
await apiAssetUserAdd(formData)
}
ElMessage.success('操作成功')
dialogVisible.value = false
getList()
} catch (e) {
console.error(e)
} finally {
submitLoading.value = false
}
}
})
}
onMounted(() => {
getList()
})
</script>
+479 -97
View File
@@ -407,20 +407,35 @@
</div>
<div class="rx-herbs">
<div
v-for="(h, i) in slipHerbsList"
:key="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="slipMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in slipMainHerbs"
: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="slipAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in slipAuxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
</div>
</div>
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
<div class="rx-text">
<p>服法{{ rxUsageText }}</p>
<p>主方服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">辅方服法{{ rxAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
@@ -649,53 +664,87 @@
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<div class="flex flex-wrap items-center gap-2">
<el-button type="primary" size="small" @click="addHerb">
添加药材
</el-button>
<el-button type="success" size="small" @click="openLibraryDialog">
<el-button
type="success"
size="small"
v-perms="['cf.prescription/add', 'cf.prescription/edit', 'tcm.prescriptionLibrary/lists']"
@click="openLibraryDialog"
>
从处方库导入
</el-button>
</div>
<div class="mt-2">
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
导入药方
</el-button>
<span class="text-xs text-gray-500 ml-2 align-middle">
粘贴文本解析药名与剂量须与药品库名称完全一致才录入
<span class="text-xs text-gray-500">
粘贴文本解析药名与剂量须与药品库名称完全一致才录入导入至主方
</span>
</div>
<el-table :data="editForm.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="row.name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row, $index }">
<el-input-number
v-model="row.dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="removeHerb($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="herb-formula-block mt-3">
<div class="herb-formula-block__head">
<el-tag type="primary" size="small">主方</el-tag>
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
</div>
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="editForm.herbs[row.index].dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="herb-formula-block mt-4">
<div class="herb-formula-block__head">
<el-tag type="warning" size="small">辅方</el-tag>
<el-button type="primary" size="small" plain @click="addHerb('辅方')">
添加辅方药材
</el-button>
</div>
<el-table :data="auxHerbRows" class="mt-2" border empty-text="暂无辅方药材">
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="editForm.herbs[row.index].dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-alert
v-if="editMode === 'edit'"
type="warning"
@@ -778,8 +827,10 @@
<el-option label="汤剂" value="汤剂" />
</el-select>
</el-form-item> </el-col>
<!-- 用量字段 -->
</el-row>
<div class="usage-formula-title usage-formula-title--main">主方用法</div>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="用量" prop="dosage_amount">
<!-- 浓缩水丸1-10g 下拉选择 -->
@@ -881,6 +932,97 @@
</el-form-item>
</el-col>
</el-row>
<template v-if="auxHerbRows.length > 0">
<div class="usage-formula-title usage-formula-title--aux">辅方用法</div>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="用量">
<el-select
v-if="editForm.prescription_type === '浓缩水丸'"
v-model="editForm.aux_usage.dosage_amount"
placeholder="请选择用量"
class="w-full"
>
<el-option v-for="n in 10" :key="n" :label="n + 'g'" :value="n" />
</el-select>
<el-select
v-else-if="editForm.prescription_type === '饮片'"
v-model="editForm.aux_usage.dosage_amount"
placeholder="请选择用量"
class="w-full"
>
<el-option label="50ml" :value="50" />
<el-option label="100ml" :value="100" />
<el-option label="120ml" :value="120" />
<el-option label="150ml" :value="150" />
<el-option label="180ml" :value="180" />
<el-option label="200ml" :value="200" />
<el-option label="250ml" :value="250" />
</el-select>
<el-input-number
v-else
v-model="editForm.aux_usage.dosage_amount"
:min="0"
:precision="2"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " label-width="12px">
<el-select
v-model="editForm.aux_usage.dosage_bag_count"
placeholder="请选择袋数"
class="w-[120px]"
>
<el-option label="1袋" :value="1" />
<el-option label="2袋" :value="2" />
<el-option label="3袋" :value="3" />
<el-option label="4袋" :value="4" />
<el-option label="5袋" :value="5" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
<el-form-item label="是否代煎">
<el-radio-group v-model="editForm.aux_usage.need_decoction">
<el-radio :label="true">代煎</el-radio>
<el-radio :label="false">不代煎</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
<el-form-item label="每贴出包数">
<el-select v-model="editForm.aux_usage.bags_per_dose" placeholder="请选择" class="w-full">
<el-option v-for="n in 9" :key="n" :label="n + '包'" :value="n" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="每天几次">
<el-input-number
v-model="editForm.aux_usage.times_per_day"
:min="1"
:max="6"
class="!w-full"
placeholder="每天服用次数"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用天数">
<el-input-number
v-model="editForm.aux_usage.usage_days"
:min="1"
:max="365"
placeholder="服用天数"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
</template>
<el-form-item label="用法" prop="usage_instruction">
<el-input
@@ -1365,16 +1507,17 @@
</template>
</el-dialog>
<!-- 从处方库导入与诊间 tcm-prescription 一致当前处方开方医师 creator_id 筛选 -->
<!-- 从处方库导入当前开方医师的全部模板 + 所有人可见的公共模板 -->
<el-dialog
v-model="showLibraryDialog"
title="从处方库导入"
width="800px"
:close-on-click-modal="false"
>
<div class="mb-4">
<div class="mb-4 flex gap-3">
<el-input
v-model="librarySearchName"
class="flex-1"
placeholder="请输入处方名称搜索"
clearable
@keyup.enter="searchLibrary"
@@ -1384,6 +1527,24 @@
<el-button :icon="Search" @click="searchLibrary" />
</template>
</el-input>
<el-select
v-model="librarySearchFormulaType"
placeholder="处方类型"
clearable
class="w-[140px]"
@change="searchLibrary"
>
<el-option label="全部" value="" />
<el-option label="主方" value="主方" />
<el-option label="辅方" value="辅方" />
</el-select>
</div>
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="libraryImportMode">
<el-radio label="replace">覆盖现有药材</el-radio>
<el-radio label="append">追加到末尾</el-radio>
</el-radio-group>
</div>
<el-table
@@ -1394,6 +1555,13 @@
@row-click="handleSelectLibraryRow"
>
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
<el-table-column label="处方类型" width="90">
<template #default="{ row }">
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
{{ row.formula_type || '主方' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="药材数量" width="100">
<template #default="{ row }">
{{ row.herbs?.length || 0 }}
@@ -1407,6 +1575,13 @@
<span v-else class="text-gray-400">暂无药材</span>
</template>
</el-table-column>
<el-table-column label="是否公开" width="110">
<template #default="{ row }">
<el-tag :type="row.is_public ? 'success' : 'info'" size="small">
{{ row.is_public ? '所有人可见' : '仅自己可见' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" width="100" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
@@ -1522,6 +1697,122 @@ import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方'
type HerbRow = { name: string; dosage: number; formula_type: FormulaType }
type AuxUsageForm = {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
/** 从处方库导入辅方时写入的模板名称,供药房联展示 */
prescription_name?: string
}
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
function normalizeAuxUsageForm(raw: unknown, prescriptionType: string): AuxUsageForm {
const base = defaultAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days,
prescription_name: String(o.prescription_name ?? '').trim()
}
}
function buildUsageSegmentText(
usage: {
prescription_type?: string
dosage_amount?: number | null
dosage_unit?: string
dosage_bag_count?: number
times_per_day?: number
usage_way?: string
usage_time?: string
},
fallbackWay?: string,
fallbackTime?: string
): string {
const pt = usage.prescription_type || '浓缩水丸'
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
const usageWay = usage.usage_way || fallbackWay || '温水送服'
const usageTime = usage.usage_time || fallbackTime || ''
const seg: string[] = []
seg.push(`每天${times}`)
if (pt === '浓缩水丸') {
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
seg.push(`一次${bags}`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
}
function normalizeFormulaType(v: unknown): FormulaType {
return v === '辅方' ? '辅方' : '主方'
}
function normalizeHerbRow(raw: any): HerbRow {
return {
name: String(raw?.name ?? '').trim(),
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
}
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => normalizeHerbRow(x))
}
return []
}
/** 与 server/config/project.php prescription_audit_roles 保持一致 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
@@ -1923,7 +2214,13 @@ const createOrderHerbSummary = computed(() => {
const row = createOrderPrescription.value
const herbs = normalizeSlipHerbs(row?.herbs)
if (!herbs.length) return '暂无药材明细'
const s = herbs.map((h) => `${h.name} ${h.dosage}g`).join('、')
const main = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
const aux = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
const fmt = (list: HerbRow[]) => list.map((h) => `${h.name} ${h.dosage}g`).join('、')
const parts: string[] = []
if (main.length) parts.push(`主方:${fmt(main)}`)
if (aux.length) parts.push(`辅方:${fmt(aux)}`)
const s = parts.join('')
return s.length > 120 ? `${s.slice(0, 120)}` : s
})
@@ -2163,9 +2460,27 @@ const editReviveHint = computed(() => {
const slipHerbsList = computed(() => {
const h = slipView.value?.herbs
return Array.isArray(h) ? h : []
return Array.isArray(h) ? (h as HerbRow[]) : []
})
const slipMainHerbs = computed(() =>
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
)
const slipAuxHerbs = computed(() =>
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
)
const mainHerbRows = computed(() =>
editForm.herbs
.map((herb, index) => ({ herb, index }))
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '主方')
)
const auxHerbRows = computed(() =>
editForm.herbs
.map((herb, index) => ({ herb, index }))
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
)
const slipDietaryText = computed(() => {
const d = slipView.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
@@ -2251,23 +2566,26 @@ const rxUsageText = computed(() => {
const v = slipView.value as any
if (!v) return '—'
if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服'
const usageTime = v.usage_time || ''
const seg: string[] = []
seg.push(`每天${times}`)
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
seg.push(`一次${bags}`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
return buildUsageSegmentText(v)
})
const rxAuxUsageText = computed(() => {
const v = slipView.value as any
if (!v || !slipAuxHerbs.value.length) return ''
const aux = normalizeAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
return buildUsageSegmentText(
{
prescription_type: v.prescription_type,
dosage_amount: aux.dosage_amount,
dosage_unit: v.dosage_unit,
dosage_bag_count: aux.dosage_bag_count,
times_per_day: aux.times_per_day,
usage_way: v.usage_way,
usage_time: v.usage_time
},
v.usage_way,
v.usage_time
)
})
const rxAdviceText = computed(() => {
@@ -2462,7 +2780,7 @@ const editForm = reactive({
pulse: '',
pulse_condition: '',
clinical_diagnosis: '',
herbs: [] as Array<{ name: string; dosage: number }>,
herbs: [] as HerbRow[],
dose_count: 7,
dose_unit: '剂',
usage_days: 7,
@@ -2488,7 +2806,8 @@ const editForm = reactive({
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
business_prescription_audit_rejected: 0,
business_prescription_audit_remark: '',
times_per_day: 2
times_per_day: 2,
aux_usage: defaultAuxUsage('浓缩水丸')
})
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
@@ -2579,6 +2898,7 @@ watch(() => editForm.prescription_type, (newType) => {
editForm.need_decoction = false
editForm.bags_per_dose = 1
}
Object.assign(editForm.aux_usage, defaultAuxUsage(newType))
})
//
@@ -2807,15 +3127,15 @@ function slipAgeText(age: unknown) {
return `${age}`
}
function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => ({
name: String(x?.name ?? '').trim(),
dosage: Number(x?.dosage) || 0
}))
function herbValidationLabel(globalIndex: number): string {
const herb = editForm.herbs[globalIndex]
if (!herb) return `${globalIndex + 1}`
const ft = normalizeFormulaType(herb.formula_type)
let n = 0
for (let i = 0; i <= globalIndex; i++) {
if (normalizeFormulaType(editForm.herbs[i]?.formula_type) === ft) n++
}
return []
return `${ft}${n}`
}
function listRxOrderWarnings(row: any): string[] {
@@ -3038,6 +3358,8 @@ const showLibraryDialog = ref(false)
const libraryLoading = ref(false)
const libraryList = ref<any[]>([])
const librarySearchName = ref('')
const librarySearchFormulaType = ref('')
const libraryImportMode = ref<'replace' | 'append'>('replace')
const libraryPage = ref(1)
const libraryPageSize = ref(15)
const libraryTotal = ref(0)
@@ -3082,8 +3404,8 @@ const loadLibraryList = async () => {
page_no: libraryPage.value,
page_size: libraryPageSize.value,
prescription_name: librarySearchName.value,
is_public: '',
creator_id: doctorId
formula_type: librarySearchFormulaType.value,
prescribing_creator_id: doctorId
})
libraryList.value = res?.lists || []
libraryTotal.value = res?.count || 0
@@ -3118,18 +3440,37 @@ const handleImportLibrary = (row: any) => {
feedback.msgWarning('该处方没有药材信息')
return
}
const imported = JSON.parse(JSON.stringify(row.herbs)) as Array<{ name: string; dosage: number }>
editForm.herbs = imported
const formulaType = normalizeFormulaType(row.formula_type)
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
...normalizeHerbRow(h),
formula_type: formulaType
}))
if (libraryImportMode.value === 'replace') {
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
editForm.herbs = [...kept, ...imported]
} else {
editForm.herbs.push(...imported)
}
if (formulaType === '辅方') {
editForm.aux_usage.prescription_name = String(row.prescription_name ?? '').trim()
}
const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : ''
if (findDuplicateHerbNamesLocal().length) {
feedback.msgWarning('导入的处方中存在重复药名,请合并剂量或删除多余行')
feedback.msgWarning(
`已导入处方「${row.prescription_name}${modeHint},共${imported.length}味药材。存在重复药名,请合并剂量或删除多余行`
)
} else {
feedback.msgSuccess(`已导入处方「${row.prescription_name}${modeHint},共${imported.length}味药材`)
}
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${imported.length}味药材`)
showLibraryDialog.value = false
}
watch(showLibraryDialog, (open) => {
if (open) {
librarySearchName.value = ''
librarySearchFormulaType.value = ''
libraryImportMode.value = 'replace'
libraryPage.value = 1
loadLibraryList()
}
@@ -3261,7 +3602,7 @@ async function handlePasteRecipeImport() {
}
pasteRecipeImportLoading.value = true
try {
const resolved: Array<{ name: string; dosage: number }> = []
const resolved: HerbRow[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(row.name)
@@ -3269,7 +3610,7 @@ async function handlePasteRecipeImport() {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage })
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
@@ -3281,7 +3622,8 @@ async function handlePasteRecipeImport() {
return
}
if (pasteRecipeImportMode.value === 'replace') {
editForm.herbs = resolved
const auxKept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
editForm.herbs = [...auxKept, ...resolved]
} else {
editForm.herbs.push(...resolved)
}
@@ -3306,11 +3648,12 @@ async function handlePasteRecipeImport() {
}
}
//
const addHerb = () => {
// /
const addHerb = (formulaType: FormulaType = '主方') => {
editForm.herbs.push({
name: '',
dosage: 0
dosage: 0,
formula_type: formulaType
})
}
@@ -3360,6 +3703,8 @@ const resetForm = () => {
editForm.is_system_auto = 0
editForm.business_prescription_audit_rejected = 0
editForm.business_prescription_audit_remark = ''
editForm.times_per_day = 2
Object.assign(editForm.aux_usage, defaultAuxUsage('浓缩水丸'))
clearRxLinkedOrderHint()
}
@@ -3458,7 +3803,7 @@ const handleEdit = async (row: any) => {
editForm.pulse = src.pulse || ''
editForm.pulse_condition = src.pulse_condition || ''
editForm.clinical_diagnosis = src.clinical_diagnosis || ''
editForm.herbs = src.herbs ? JSON.parse(JSON.stringify(src.herbs)) : []
editForm.herbs = src.herbs ? (src.herbs as any[]).map((h) => normalizeHerbRow(h)) : []
editForm.dose_count = src.dose_count ?? 7
editForm.dose_unit = src.dose_unit || '剂'
editForm.usage_days = src.usage_days ?? 7
@@ -3483,6 +3828,10 @@ const handleEdit = async (row: any) => {
editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0
editForm.business_prescription_audit_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
Object.assign(
editForm.aux_usage,
normalizeAuxUsageForm(src.aux_usage, editForm.prescription_type)
)
// watch
// 使 setTimeout
@@ -3517,12 +3866,13 @@ const handleSubmit = async () => {
for (let i = 0; i < editForm.herbs.length; i++) {
const herb = editForm.herbs[i]
const label = herbValidationLabel(i)
if (!herb.name || !herb.name.trim()) {
feedback.msgError(`${i + 1}药材名称不能为空`)
feedback.msgError(`${label}药材名称不能为空`)
return
}
if (!herb.dosage || herb.dosage <= 0) {
feedback.msgError(`${i + 1}药材剂量必须大于0`)
feedback.msgError(`${label}药材剂量必须大于0`)
return
}
}
@@ -3946,6 +4296,38 @@ onMounted(async () => {
box-sizing: border-box;
}
.rx-herb-section-label {
grid-column: 1 / -1;
font-size: 12px;
font-weight: 600;
color: #409eff;
line-height: 1.4;
}
.rx-herb-section-label--aux {
color: #e6a23c;
margin-top: 4px;
}
.herb-formula-block__head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.usage-formula-title {
font-size: 13px;
font-weight: 600;
margin: 8px 0 4px;
color: #409eff;
}
.usage-formula-title--aux {
color: #e6a23c;
margin-top: 12px;
}
.rx-herb-cell {
display: grid;
grid-template-columns: 1fr 64px;
@@ -11,6 +11,13 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item class="w-[200px]" label="处方类型">
<el-select v-model="formData.formula_type" placeholder="全部" clearable>
<el-option label="全部" :value="''" />
<el-option label="主方" value="主方" />
<el-option label="辅方" value="辅方" />
</el-select>
</el-form-item>
<el-form-item class="w-[200px]" label="是否公开">
<el-select v-model="formData.is_public" placeholder="全部" clearable>
<el-option label="全部" :value="''" />
@@ -37,6 +44,13 @@
<el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" />
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
<el-table-column label="处方类型" min-width="100">
<template #default="{ row }">
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
{{ row.formula_type || '主方' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="药材数量" min-width="100">
<template #default="{ row }">
{{ row.herbs?.length || 0 }}
@@ -114,6 +128,13 @@
show-word-limit
/>
</el-form-item>
<el-form-item label="处方类型" prop="formula_type">
<el-radio-group v-model="editForm.formula_type">
<el-radio value="主方">主方</el-radio>
<el-radio value="辅方">辅方</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
@@ -200,6 +221,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
//
const formData = reactive({
prescription_name: '',
formula_type: '',
is_public: ''
})
@@ -212,6 +234,7 @@ const formRef = ref<FormInstance>()
const editForm = reactive({
id: 0,
prescription_name: '',
formula_type: '主方',
herbs: [] as Array<{ name: string; dosage: number }>,
is_public: 0
})
@@ -267,6 +290,7 @@ const removeHerb = (index: number) => {
const resetForm = () => {
editForm.id = 0
editForm.prescription_name = ''
editForm.formula_type = '主方'
editForm.herbs = []
editForm.is_public = 0
}
@@ -283,6 +307,7 @@ const handleView = (row: any) => {
editMode.value = 'view'
editForm.id = row.id
editForm.prescription_name = row.prescription_name || ''
editForm.formula_type = row.formula_type || '主方'
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0
showEdit.value = true
@@ -293,6 +318,7 @@ const handleEdit = (row: any) => {
editMode.value = 'edit'
editForm.id = row.id
editForm.prescription_name = row.prescription_name || ''
editForm.formula_type = row.formula_type || '主方'
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0
showEdit.value = true
@@ -216,6 +216,22 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item v-if="showAuditAdminFilter" class="w-[200px]" label="下单人">
<el-select
v-model="queryParams.audit_admin_id"
clearable
filterable
placeholder="全部下单人"
class="!w-full"
>
<el-option
v-for="a in auditAdminOptions"
:key="a.id"
:label="a.name"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item class="w-[200px]" label="医生">
<el-select
v-model="queryParams.doctor_id"
@@ -277,7 +293,7 @@
:fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams"
:page-size="pager.size"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「签收日期」与详情/业绩看板同源:优先库表轨迹;库内无轨迹时导出会按 logisticsTrace 口径补查快递100 并落库(如 PO 单在详情已签收但库内为空)。"
/>
</el-form-item>
</el-form>
@@ -915,31 +931,75 @@
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
<div class="flex flex-col gap-1 text-sm leading-relaxed">
<div>
<span v-if="detailHasAuxHerbs" class="text-gray-500 mr-1">主方</span>
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</div>
<div v-if="detailHasAuxHerbs && detailAuxUsage">
<span class="text-gray-500 mr-1">辅方</span>
<template v-if="detailAuxUsage.dosage_amount != null && detailAuxUsage.dosage_amount !== 0">
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
? detailPrescription.usage_days + ' 天'
: '—'
}}
</div>
<template v-if="detailHasAuxHerbs">
<div>
<span class="text-gray-500 mr-1">主方</span>
每天
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '— 次' }}
· 处方开立
{{
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
? detailPrescription.usage_days + ' 天'
: '—'
}}
</div>
<div v-if="detailAuxUsage">
<span class="text-gray-500 mr-1">辅方</span>
每天
{{ detailAuxUsage.times_per_day ? detailAuxUsage.times_per_day + ' 次' : '— 次' }}
· 处方开立
{{
detailAuxUsage.usage_days != null && Number(detailAuxUsage.usage_days) > 0
? detailAuxUsage.usage_days + ' 天'
: '— 天'
}}
</div>
</template>
<template v-else>
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
? detailPrescription.usage_days + ' 天'
: '—'
}}
</div>
</template>
<div>
<span class="text-gray-500">订单设置</span>
{{
@@ -1236,6 +1296,7 @@
<template #header>
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-[15px]">物流轨迹</span>
<span class="text-xs text-gray-400 font-mono">{{ detailData.tracking_number }}</span>
<el-tag
v-if="detailLogisticsUrgent.show"
type="danger"
@@ -2302,7 +2363,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">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
@@ -2375,17 +2436,63 @@
</div>
</div>
<div class="rx-herbs">
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
<span class="rx-herb-name" v-if="prescriptionTabType === 'internal'">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-name" v-else>{{ h.name }}</span>
<span class="rx-herb-total" v-if="prescriptionTabType === 'internal'">{{ rxHerbTotal(h.dosage) }}</span>
<span class="rx-herb-total" v-else>{{ h.dosage }}</span>
</div>
<template v-if="prescriptionTabType === 'internal'">
<template v-if="slipMainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in slipMainHerbs"
: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="slipAuxHerbs.length">
<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"
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-else>
<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>
<div class="rx-text">
<p>服法{{ rxUsageText }}</p>
<p v-if="prescriptionTabType === 'internal' || rxAuxUsageText">服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">辅服法{{ rxAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
@@ -2627,6 +2734,7 @@ import {
prescriptionOrderSubmitGancaoRecipel,
prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail,
prescriptionLibraryLists,
getDoctors,
getAssistants
} from '@/api/tcm'
@@ -2661,9 +2769,39 @@ function openDiagnosisPatientDetailFromOrder() {
/** 诊间医助角色(与 DiagnosisLists 等一致) */
const TCM_ASSISTANT_ROLE_ID = 2
/** 下单角色(与 server/config/project.php prescription_order_placer_role_ids 一致) */
const ORDER_PLACER_ROLE_ID = 6
/** 拥有以下角色时可按任意审核人筛选(与 prescription_order_placer_exempt_role_ids 一致) */
const ORDER_PLACER_EXEMPT_ROLE_IDS = [3, 8]
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
/** 「下单」角色:下单人筛选仅允许查本人审核过的订单 */
const isOrderPlacerAuditFilterRestricted = computed(() => {
const u = userStore.userInfo
if (!u || Number(u.root) === 1) return false
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
if (!ids.includes(ORDER_PLACER_ROLE_ID)) return false
return !ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
})
/** 超管 / 经理 / 管理员:可按任意下单人筛选 */
const canSearchAnyAuditAdmin = computed(() => {
const u = userStore.userInfo
if (!u) return false
if (Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
return ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
})
/** 是否展示「下单人」筛选(下单角色 + 经理/管理员/超管) */
const showAuditAdminFilter = computed(
() => isOrderPlacerAuditFilterRestricted.value || canSearchAnyAuditAdmin.value
)
/** 下单人下拉(lists extend.audit_admin_options,下单角色用户) */
const auditAdminOptions = ref<Array<{ id: number; name: string }>>([])
/**
* 是否展示处方审核筛选纯医助含角色 2 且不具备处方审核角色仅能用支付单审核筛选
*/
@@ -2901,7 +3039,9 @@ const queryParams = reactive({
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0' */
service_channel: '' as '' | '0',
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
exclude_fulfillment_cancelled: 0 as number
exclude_fulfillment_cancelled: 0 as number,
/** 下单人(关联操作日志 audit_rx_* / audit_pay_* */
audit_admin_id: '' as number | ''
})
const rxAuditTabs = [
@@ -3014,6 +3154,11 @@ function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): R
}
if (!p.order_no) delete p.order_no
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
if (p.audit_admin_id === '' || p.audit_admin_id === undefined || p.audit_admin_id === null) {
delete p.audit_admin_id
} else {
p.audit_admin_id = Number(p.audit_admin_id)
}
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
if (!p.express_company || p.express_company === '') delete p.express_company
if (p.service_channel === '' || p.service_channel === undefined || p.service_channel === null) {
@@ -3049,6 +3194,24 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
params: queryParams
})
watch(
() => (pager.extend as Record<string, unknown> | undefined)?.audit_admin_options,
(raw) => {
if (!Array.isArray(raw)) return
auditAdminOptions.value = raw
.map((r: unknown) => {
if (r == null || typeof r !== 'object') return null
const row = r as Record<string, unknown>
const id = Number(row.id)
const name = String(row.name ?? '').trim()
if (!Number.isFinite(id) || id <= 0 || name === '') return null
return { id, name }
})
.filter((x): x is { id: number; name: string } => x != null)
},
{ immediate: true }
)
/** 业绩看板等入口:URL 携带创建区间、诊单医助部门 / 医助 id */
function applyRouteQueryToPrescriptionOrderList() {
const q = route.query
@@ -3240,6 +3403,7 @@ function handleReset() {
queryParams.payment_slip_audit_status = ''
queryParams.supply_mode = ''
queryParams.service_channel = ''
queryParams.audit_admin_id = ''
queryParams.exclude_fulfillment_cancelled = 0
resetParams()
}
@@ -3757,6 +3921,20 @@ const detailLinkedAppointmentChannelText = computed(() => {
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
/** 详情:是否含辅方药材(用于决定是否展示辅方用量 / 用法) */
const detailHasAuxHerbs = computed(() => {
const herbs = detailPrescription.value?.herbs
if (!Array.isArray(herbs)) return false
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
})
/** 详情:辅方用法(aux_usage JSON 经规范化后的对象,与处方类型一致) */
const detailAuxUsage = computed(() => {
const rx = detailPrescription.value as any
if (!rx) return null
return normalizeSlipAuxUsageForm(rx.aux_usage, rx.prescription_type || '浓缩水丸')
})
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
@@ -3812,6 +3990,40 @@ const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
const logisticsTracePhoneTail = ref('')
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
let logisticsTraceRequestSeq = 0
type LogisticsTraceRequestContext = {
requestSeq: number
orderId: number
trackingNumber: string
}
function bumpLogisticsTraceRequestToken() {
logisticsTraceRequestSeq += 1
logisticsTracePayload.value = null
}
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
if (Number(detailData.value?.id) !== ctx.orderId) return false
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
}
function parseLogisticsTracePayload(
res: unknown,
expectedTrackingNumber: string,
expectedOrderId: number
): Record<string, any> | null {
const raw = (res as { data?: unknown })?.data ?? res
if (!raw || typeof raw !== 'object') return null
const payload = raw as Record<string, any>
const respNum = String(payload.tracking_number || '').trim()
if (respNum && respNum !== expectedTrackingNumber) return null
const respOrderId = Number(payload.order_id)
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
return payload
}
const detailLogs = ref<any[]>([])
const updateAmountVisible = ref(false)
@@ -3977,24 +4189,33 @@ function expressCompanyLabel(v: unknown) {
}
async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id)
if (!id) return
const orderId = Number(detailData.value?.id)
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
if (!orderId || !trackingNumber) return
const requestSeq = ++logisticsTraceRequestSeq
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
logisticsTraceLoading.value = true
try {
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id,
id: orderId,
express_company: detailLogisticsExpress.value
}
if (digits.length >= 4) {
params.phone_tail = digits
}
const res: any = await prescriptionOrderLogisticsTrace(params)
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
if (!isCurrentLogisticsTraceContext(ctx)) return
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
} catch {
if (!isCurrentLogisticsTraceContext(ctx)) return
logisticsTracePayload.value = null
} finally {
logisticsTraceLoading.value = false
if (isCurrentLogisticsTraceContext(ctx)) {
logisticsTraceLoading.value = false
}
}
}
@@ -4120,7 +4341,7 @@ async function openDetail(id: number) {
// Bug
detailData.value = null
detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null
bumpLogisticsTraceRequestToken()
detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = ''
detailLogs.value = []
@@ -4981,35 +5202,47 @@ const completeOrderDialogVisible = ref(false)
const completeOrderId = ref(0)
const completeFulfillmentStatus = ref<number>(3)
const completeOrderSubmitting = ref(false)
let completeOrderLogisticsRequestSeq = 0
function onCompleteOrderDialogClosed() {
completeOrderId.value = 0
completeOrderLogisticsRequestSeq += 1
completeOrderLogisticsPayload.value = null
completeOrderLogisticsLoading.value = false
}
async function fetchCompleteOrderLogisticsForDialog(row: {
id: number
tracking_number?: unknown
express_company?: unknown
recipient_phone?: unknown
}) {
const orderId = Number(row.id)
const trackingNumber = String(row.tracking_number || '').trim()
if (!orderId || !trackingNumber) return
const requestSeq = ++completeOrderLogisticsRequestSeq
completeOrderLogisticsLoading.value = true
completeOrderLogisticsPayload.value = null
try {
const digits = String(row.recipient_phone || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id: row.id,
id: orderId,
express_company: String(row.express_company || 'auto') || 'auto'
}
if (digits.length >= 4) {
params.phone_tail = digits
}
const res: any = await prescriptionOrderLogisticsTrace(params)
completeOrderLogisticsPayload.value = (res?.data ?? res) as Record<string, any>
if (requestSeq !== completeOrderLogisticsRequestSeq || completeOrderId.value !== orderId) return
completeOrderLogisticsPayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
} catch {
if (requestSeq !== completeOrderLogisticsRequestSeq || completeOrderId.value !== orderId) return
completeOrderLogisticsPayload.value = null
} finally {
completeOrderLogisticsLoading.value = false
if (requestSeq === completeOrderLogisticsRequestSeq && completeOrderId.value === orderId) {
completeOrderLogisticsLoading.value = false
}
}
}
@@ -5262,22 +5495,129 @@ 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)
type SlipFormulaType = '主方' | '辅方'
type SlipAuxUsageForm = {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
}
function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
return v === '辅方' ? '辅方' : '主方'
}
function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
const base = defaultSlipAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
}
}
function buildSlipUsageSegmentText(
usage: {
prescription_type?: string
dosage_amount?: number | null
dosage_unit?: string
dosage_bag_count?: number
times_per_day?: number
usage_way?: string
usage_time?: string
},
fallbackWay?: string,
fallbackTime?: string
): string {
const pt = usage.prescription_type || '浓缩水丸'
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
const usageWay = usage.usage_way || fallbackWay || '温水送服'
const usageTime = usage.usage_time || fallbackTime || ''
const seg: string[] = []
seg.push(`每天${times}`)
if (pt === '浓缩水丸') {
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
seg.push(`一次${bags}`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
}
//
const slipHerbsList = computed(() => {
const h = prescriptionViewData.value?.herbs
return Array.isArray(h) ? h : []
})
const slipMainHerbs = computed(() =>
slipHerbsList.value.filter((h: any) => normalizeSlipFormulaType(h?.formula_type) === '主方')
)
const slipAuxHerbs = computed(() =>
slipHerbsList.value.filter((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
)
const slipDietaryText = computed(() => {
const d = prescriptionViewData.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
@@ -5313,6 +5653,22 @@ const slipPillGrams = computed(() => {
return days * times * doseG * bags
})
/** 辅方出丸克数:仅含辅方药材且为浓缩水丸时计算;公式同主方,用 aux_usage 字段 */
const slipAuxPillGrams = computed(() => {
const d = prescriptionViewData.value as any
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
if (!slipAuxHerbs.value.length) return null
const aux = normalizeSlipAuxUsageForm(d.aux_usage, d.prescription_type || '浓缩水丸')
/** 辅方疗程优先用业务订单 medication_days(与主方共享一次配药周期),缺省回退辅方 usage_days */
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
})
/* ============================================================
* A4 处方笺药房联相关计算 consumer/prescription/index.vue 保持一致
* ============================================================ */
@@ -5404,29 +5760,36 @@ const rxUsageText = computed(() => {
const v = prescriptionViewData.value as any
if (!v) return '—'
if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服'
const usageTime = v.usage_time || ''
const seg: string[] = []
seg.push(`每天${times}`)
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
seg.push(`一次${bags}`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
return buildSlipUsageSegmentText(v)
})
/** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
const rxAuxUsageText = computed(() => {
const v = prescriptionViewData.value as any
if (!v || !slipAuxHerbs.value.length) return ''
const aux = normalizeSlipAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
return buildSlipUsageSegmentText(
{
prescription_type: v.prescription_type,
dosage_amount: aux.dosage_amount,
dosage_unit: v.dosage_unit,
dosage_bag_count: aux.dosage_bag_count,
times_per_day: aux.times_per_day,
usage_way: v.usage_way,
usage_time: v.usage_time
},
v.usage_way,
v.usage_time
)
})
/** 出丸:优先用 slipPillGrams / slipAuxPillGrams(含辅方时相加),否则按药材总量×剂数 */
const rxOutPelletGrams = computed(() => {
const pill = slipPillGrams.value
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
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 formatNum(sum)
const v = prescriptionViewData.value as any
if (!v) return ''
const explicit = v.out_pellet || v.total_weight
@@ -5462,7 +5825,17 @@ const rxPharmacyRemarkText = computed(() => {
const rxOutPelletText = computed(() => {
const out = rxOutPelletGrams.value
return out ? `${out}` : ''
if (!out) return ''
/** 含辅方时附带「主方 X克 + 辅方 Y克」明细,便于药房核对 */
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}克(主方 ${formatNum(main)}克 + 辅方 ${formatNum(aux)}克)`
}
return `${out}`
})
function formatNum(n: number): string {
@@ -5534,6 +5907,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('该订单未关联处方')
@@ -5543,6 +5970,7 @@ async function openPrescriptionView(row: any) {
prescriptionViewVisible.value = true
prescriptionViewLoading.value = true
prescriptionViewData.value = null
slipAuxLibraryName.value = ''
prescriptionTabType.value = 'internal'
try {
@@ -5577,12 +6005,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
}
@@ -6223,6 +6654,24 @@ async function downloadPrescriptionSlipPdf() {
box-sizing: border-box;
}
.rx-herb-section-label {
grid-column: 1 / -1;
font-size: 12px;
font-weight: 600;
color: #409eff;
line-height: 1.4;
}
.rx-herb-section-label--aux {
color: #e6a23c;
margin-top: 4px;
}
.rx-herb-library-name {
font-weight: 500;
margin-left: 2px;
}
.rx-herb-cell {
display: grid;
grid-template-columns: 1fr 64px;
@@ -234,6 +234,22 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item v-if="showAuditAdminFilter" class="w-[200px]" label="下单人">
<el-select
v-model="queryParams.audit_admin_id"
clearable
filterable
placeholder="全部下单人"
class="!w-full"
>
<el-option
v-for="a in auditAdminOptions"
:key="a.id"
:label="a.name"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item class="w-[200px]" label="医生">
<el-select
v-model="queryParams.doctor_id"
@@ -2075,7 +2091,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">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
@@ -2440,9 +2456,39 @@ function openDiagnosisPatientDetailFromOrder() {
/** 诊间医助角色(与 DiagnosisLists 等一致) */
const TCM_ASSISTANT_ROLE_ID = 2
/** 下单角色(与 server/config/project.php prescription_order_placer_role_ids 一致) */
const ORDER_PLACER_ROLE_ID = 6
/** 拥有以下角色时可按任意审核人筛选(与 prescription_order_placer_exempt_role_ids 一致) */
const ORDER_PLACER_EXEMPT_ROLE_IDS = [3, 8]
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
/** 「下单」角色:下单人筛选仅允许查本人审核过的订单 */
const isOrderPlacerAuditFilterRestricted = computed(() => {
const u = userStore.userInfo
if (!u || Number(u.root) === 1) return false
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
if (!ids.includes(ORDER_PLACER_ROLE_ID)) return false
return !ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
})
/** 超管 / 经理 / 管理员:可按任意下单人筛选 */
const canSearchAnyAuditAdmin = computed(() => {
const u = userStore.userInfo
if (!u) return false
if (Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
return ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
})
/** 是否展示「下单人」筛选(下单角色 + 经理/管理员/超管) */
const showAuditAdminFilter = computed(
() => isOrderPlacerAuditFilterRestricted.value || canSearchAnyAuditAdmin.value
)
/** 下单人下拉(lists extend.audit_admin_options,下单角色用户) */
const auditAdminOptions = ref<Array<{ id: number; name: string }>>([])
/**
* 是否展示处方审核筛选纯医助含角色 2 且不具备处方审核角色仅能用支付单审核筛选
*/
@@ -2673,7 +2719,9 @@ const queryParams = reactive({
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
supply_mode: '' as '' | 'gancao' | 'self'
supply_mode: '' as '' | 'gancao' | 'self',
/** 下单人(关联操作日志 audit_rx_* / audit_pay_* */
audit_admin_id: '' as number | ''
})
const rxAuditTabs = [
@@ -2767,6 +2815,11 @@ async function fetchLists(params: Record<string, unknown>) {
}
if (!p.order_no) delete p.order_no
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
if (p.audit_admin_id === '' || p.audit_admin_id === undefined || p.audit_admin_id === null) {
delete p.audit_admin_id
} else {
p.audit_admin_id = Number(p.audit_admin_id)
}
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
if (!p.express_company || p.express_company === '') delete p.express_company
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
@@ -2789,6 +2842,24 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
params: queryParams
})
watch(
() => (pager.extend as Record<string, unknown> | undefined)?.audit_admin_options,
(raw) => {
if (!Array.isArray(raw)) return
auditAdminOptions.value = raw
.map((r: unknown) => {
if (r == null || typeof r !== 'object') return null
const row = r as Record<string, unknown>
const id = Number(row.id)
const name = String(row.name ?? '').trim()
if (!Number.isFinite(id) || id <= 0 || name === '') return null
return { id, name }
})
.filter((x): x is { id: number; name: string } => x != null)
},
{ immediate: true }
)
/** 选医助部门后,医助下拉仅显示该部门及子部门成员(与后台含子级一致) */
const filteredAssistantOptions = computed(() => {
const deptRaw = queryParams.assistant_dept_id
@@ -2950,6 +3021,7 @@ function handleReset() {
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.supply_mode = ''
queryParams.audit_admin_id = ''
resetParams()
}
@@ -3440,6 +3512,40 @@ const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
const logisticsTracePhoneTail = ref('')
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
let logisticsTraceRequestSeq = 0
type LogisticsTraceRequestContext = {
requestSeq: number
orderId: number
trackingNumber: string
}
function bumpLogisticsTraceRequestToken() {
logisticsTraceRequestSeq += 1
logisticsTracePayload.value = null
}
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
if (Number(detailData.value?.id) !== ctx.orderId) return false
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
}
function parseLogisticsTracePayload(
res: unknown,
expectedTrackingNumber: string,
expectedOrderId: number
): Record<string, any> | null {
const raw = (res as { data?: unknown })?.data ?? res
if (!raw || typeof raw !== 'object') return null
const payload = raw as Record<string, any>
const respNum = String(payload.tracking_number || '').trim()
if (respNum && respNum !== expectedTrackingNumber) return null
const respOrderId = Number(payload.order_id)
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
return payload
}
const detailLogs = ref<any[]>([])
const updateAmountVisible = ref(false)
@@ -3534,24 +3640,33 @@ function expressCompanyLabel(v: unknown) {
}
async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id)
if (!id) return
const orderId = Number(detailData.value?.id)
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
if (!orderId || !trackingNumber) return
const requestSeq = ++logisticsTraceRequestSeq
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
logisticsTraceLoading.value = true
try {
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id,
id: orderId,
express_company: detailLogisticsExpress.value
}
if (digits.length >= 4) {
params.phone_tail = digits
}
const res: any = await prescriptionOrderLogisticsTrace(params)
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
if (!isCurrentLogisticsTraceContext(ctx)) return
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
} catch {
if (!isCurrentLogisticsTraceContext(ctx)) return
logisticsTracePayload.value = null
} finally {
logisticsTraceLoading.value = false
if (isCurrentLogisticsTraceContext(ctx)) {
logisticsTraceLoading.value = false
}
}
}
@@ -3677,7 +3792,7 @@ async function openDetail(id: number) {
// Bug
detailData.value = null
detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null
bumpLogisticsTraceRequestToken()
detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = ''
detailLogs.value = []
@@ -3693,7 +3808,7 @@ async function openDetail(id: number) {
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
if (String(d.tracking_number || '').trim()) {
fetchLogisticsTrace()
void fetchLogisticsTrace()
}
fetchLogs(id)
@@ -4669,6 +4784,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),
+305 -48
View File
@@ -127,10 +127,10 @@
<span class="yeji-table-tabs-head__title">表格统计</span>
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览部门 / 排名列在滚动时保持固定</span>
</div>
<el-tabs v-model="yejiDisplayTab" class="yeji-view-tabs">
<el-tab-pane label="医生统计" name="doctor" lazy>
<el-tabs v-if="hasAnyYejiTableTab" v-model="yejiDisplayTab" class="yeji-view-tabs">
<el-tab-pane v-if="canViewDoctorTab" label="医生统计" name="doctor" lazy>
<el-card
v-if="canViewDoctorDailyStats"
v-if="canViewDoctorTab"
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
shadow="never"
>
@@ -181,15 +181,50 @@
<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="当前账号无医生统计权限" />
</el-tab-pane>
<el-tab-pane label="医助排行榜" name="leaderboard" lazy>
<el-tab-pane v-if="canViewLeaderboardTab" label="医助排行榜" name="leaderboard" lazy>
<div
v-if="leaderboardBlock || leaderboardsLoading"
class="leaderboards-wrap"
@@ -316,7 +351,7 @@
/>
</el-tab-pane>
<el-tab-pane label="甄养堂互联网医院诊金" name="zyyt" lazy>
<el-tab-pane v-if="canViewZyytTab" label="甄养堂互联网医院诊金" name="zyyt" lazy>
<!-- 业绩表4 /N 目标看板见目标看板卡片Tab -->
<div v-loading="loading" class="tables-wrap">
<div v-if="!loading && tables.length === 0" class="empty-tip">
@@ -445,12 +480,8 @@
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<<<<<<< HEAD
<b>业务订单条数</b>计业绩订单 <b>create_time</b> 落入区间<b>fulfillment_status {4,9,10}</b><b>NULL 计入</b><b>订单创建人</b>的人事部门落在该部门子树即计入表格多行命中时取最深的展示部门<b>合计业绩 / 医助排行榜接诊诊单</b>同口径选定渠道时本列仍为全量
=======
<b>业务订单条数</b>计业绩订单 <b>create_time</b> 落入区间<b>fulfillment_status {4,9,10}</b>与列表筛选
<b>assistant_dept_id</b> 时一致<b>创建人</b>人事部门优先无创建人则诊单 <b>医助</b>落在该部门子树即计入表格多行命中时取最深的展示部门与侧栏勾选与表格业绩对齐时的集合可能略有差异选定渠道时本列仍为全量
>>>>>>> master
</div>
</template>
<el-icon class="col-info"><InfoFilled /></el-icon>
@@ -636,7 +667,7 @@
</el-tab-pane>
<el-tab-pane label="目标看板(卡片)" name="zyyt_matrix" lazy>
<el-tab-pane v-if="canViewTargetMatrixTab" label="目标看板(卡片)" name="zyyt_matrix" lazy>
<div v-loading="targetProgressLoading" class="tp-matrix-wrap">
<template v-if="targetProgressCard">
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
@@ -702,18 +733,19 @@
</div>
</el-tab-pane>
</el-tabs>
<el-empty v-else description="当前账号无表格统计 Tab 权限" />
</el-card>
<el-card class="yeji-panel yeji-charts-only-card" shadow="never">
<div class="yeji-charts-only-head">统计图</div>
<div
v-loading="loading || (canViewDoctorDailyStats && doctorDailyLoading) || leaderboardsLoading"
v-loading="loading || (canViewDoctorTab && doctorDailyLoading) || (canViewLeaderboardTab && leaderboardsLoading)"
class="yeji-charts-stack"
>
<template v-if="chartsHasAnyData">
<section
v-if="
canViewDoctorDailyStats &&
canViewDoctorTab &&
(doctorDealBarHasData ||
doctorRxStackHasData ||
doctorAppointmentPieHasData ||
@@ -855,7 +887,7 @@
</section>
</template>
<template v-if="leaderboardBlock">
<template v-if="canViewLeaderboardTab && leaderboardBlock">
<section
v-for="lb in leaderboardChartBlocks"
:key="'lb-chart-' + lb.dept_id"
@@ -898,8 +930,8 @@
<el-empty
v-else-if="
!loading &&
(!canViewDoctorDailyStats || !doctorDailyLoading) &&
!leaderboardsLoading
(!canViewDoctorTab || !doctorDailyLoading) &&
(!canViewLeaderboardTab || !leaderboardsLoading)
"
class="yeji-charts-empty"
description="当前筛选下暂无图表数据,请调整条件后查询"
@@ -1159,9 +1191,19 @@
class="yeji-leadlines-dialog"
>
<template #header>
<div class="yeji-unassigned-dialog__head">
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
<div class="yeji-unassigned-dialog__head yeji-leadlines-dialog__head">
<div class="yeji-leadlines-dialog__head-main">
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
</div>
<el-button
size="small"
:loading="leadLinesExporting"
:disabled="leadLinesLoading || leadLinesExporting || leadLinesCount <= 0"
@click="exportLeadLines"
>
导出
</el-button>
</div>
</template>
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
@@ -1318,7 +1360,7 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import axios from 'axios'
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
@@ -1337,7 +1379,14 @@ import {
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
import { prescriptionOrderLists } from '@/api/tcm'
import useUserStore from '@/stores/modules/user'
import { hasPermission } from '@/utils/perm'
type YejiDisplayTab = 'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'
/** 任一权限命中即可(兼容原有接口按钮权限 + 新增 Tab 独立权限) */
function hasAnyPermission(perms: string[]): boolean {
return perms.some(p => hasPermission([p]))
}
interface DeptOption {
id: number
@@ -1626,6 +1675,7 @@ const revisitBreakdownMeta = ref<{ start: string; end: string; revisitSlot: numb
const leadLinesDialogVisible = ref(false)
const leadLinesLoading = ref(false)
const leadLinesExporting = ref(false)
const leadLinesSubtitle = ref('')
const leadLinesApiNote = ref('')
const leadLinesRows = ref<YejiLeadLineRow[]>([])
@@ -1664,14 +1714,60 @@ const appointmentLinesShowChannelColumn = computed(() =>
)
/** 图表 / 数据表切换 */
const yejiDisplayTab = ref<'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'>('zyyt')
const yejiDisplayTab = ref<YejiDisplayTab>('zyyt')
/** 与菜单 stats.doctorDailyStats/overview 一致;无权限则不展示医生统计表/图、不请求接口 */
const userStore = useUserStore()
const DOCTOR_DAILY_STATS_PERM = 'stats.doctorDailyStats/overview'
const canViewDoctorDailyStats = computed(() => {
const perms = userStore.perms || []
return perms.some(p => p === '*' || p === DOCTOR_DAILY_STATS_PERM)
/** Tab 可见性:优先认 Tab 独立权限;未配置时兼容原有接口按钮权限,避免破坏已有角色 */
const YEJI_TAB_DOCTOR_PERM = 'stats.yejiStats/tabDoctor'
const YEJI_TAB_LEADERBOARD_PERM = 'stats.yejiStats/tabLeaderboard'
const YEJI_TAB_ZYYT_PERM = 'stats.yejiStats/tabZyyt'
const YEJI_TAB_TARGET_MATRIX_PERM = 'stats.yejiStats/tabTargetMatrix'
const canViewDoctorTab = computed(() =>
hasAnyPermission([YEJI_TAB_DOCTOR_PERM, 'stats.doctorDailyStats/overview'])
)
const canViewLeaderboardTab = computed(() =>
hasAnyPermission([YEJI_TAB_LEADERBOARD_PERM, 'stats.yejiStats/leaderboard'])
)
const canViewZyytTab = computed(() =>
hasAnyPermission([
YEJI_TAB_ZYYT_PERM,
'stats.yejiStats/multi',
'stats.yejiStats/overview',
])
)
const canViewTargetMatrixTab = computed(() =>
hasAnyPermission([
YEJI_TAB_TARGET_MATRIX_PERM,
'stats.yejiStats/targetMatrix',
'stats.yejiStats/multi',
])
)
const hasAnyYejiTableTab = computed(
() =>
canViewDoctorTab.value ||
canViewLeaderboardTab.value ||
canViewZyytTab.value ||
canViewTargetMatrixTab.value
)
function pickInitialYejiTab(): YejiDisplayTab {
if (canViewZyytTab.value) return 'zyyt'
if (canViewDoctorTab.value) return 'doctor'
if (canViewLeaderboardTab.value) return 'leaderboard'
if (canViewTargetMatrixTab.value) return 'zyyt_matrix'
return 'zyyt'
}
watch(yejiDisplayTab, tab => {
if (tab === 'doctor' && !canViewDoctorTab.value) {
yejiDisplayTab.value = pickInitialYejiTab()
} else if (tab === 'leaderboard' && !canViewLeaderboardTab.value) {
yejiDisplayTab.value = pickInitialYejiTab()
} else if (tab === 'zyyt' && !canViewZyytTab.value) {
yejiDisplayTab.value = pickInitialYejiTab()
} else if (tab === 'zyyt_matrix' && !canViewTargetMatrixTab.value) {
yejiDisplayTab.value = pickInitialYejiTab()
}
})
const leaderboardChartBlocks = computed(() =>
@@ -1715,7 +1811,7 @@ const doctorRxStackHasData = computed(() =>
)
const doctorChartsHasData = computed(() => {
if (!canViewDoctorDailyStats.value) {
if (!canViewDoctorTab.value) {
return false
}
return (
@@ -1731,12 +1827,14 @@ const chartsHasAnyData = computed(() => {
if (doctorChartsHasData.value) {
return true
}
for (const tb of tables.value) {
if (yejiDeptChartHasData(tb)) {
return true
if (canViewZyytTab.value) {
for (const tb of tables.value) {
if (yejiDeptChartHasData(tb)) {
return true
}
}
}
if (leaderboardChartBlocks.value.length > 0) {
if (canViewLeaderboardTab.value && leaderboardChartBlocks.value.length > 0) {
return true
}
return false
@@ -2015,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]
@@ -2033,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[] = []
@@ -2055,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
@@ -2064,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] = ''
}
@@ -2073,6 +2190,12 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
}
async function loadDoctorDailyStats() {
if (!canViewDoctorTab.value) {
doctorDailyRows.value = []
doctorDailyTotal.value = {}
doctorDailyRange.value = null
return
}
doctorDailyLoading.value = true
try {
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
@@ -2211,6 +2334,10 @@ function daysInMonth(ym: string): number {
}
async function loadTargetProgress() {
if (!canViewTargetMatrixTab.value) {
targetProgressCard.value = null
return
}
targetProgressLoading.value = true
targetProgressCard.value = null
try {
@@ -2458,6 +2585,10 @@ async function loadChannelOptions() {
}
async function loadLeaderboard(range: { start: string; end: string }) {
if (!canViewLeaderboardTab.value) {
leaderboardBlock.value = null
return
}
leaderboardsLoading.value = true
try {
const p: Record<string, any> = {
@@ -2491,6 +2622,18 @@ async function loadLeaderboard(range: { start: string; end: string }) {
async function loadData() {
loading.value = true
leaderboardBlock.value = null
if (!canViewZyytTab.value) {
tables.value = []
loading.value = false
if (canViewDoctorTab.value) {
void loadDoctorDailyStats()
} else {
doctorDailyRows.value = []
doctorDailyTotal.value = {}
doctorDailyRange.value = null
}
return
}
try {
const baseParams: Record<string, any> = {}
if (selectedDeptIds.value.length > 0) {
@@ -2542,7 +2685,7 @@ async function loadData() {
ElMessage.error(any?.msg || any?.message || '加载失败')
} finally {
loading.value = false
if (canViewDoctorDailyStats.value) {
if (canViewDoctorTab.value) {
void loadDoctorDailyStats()
} else {
doctorDailyRows.value = []
@@ -2661,6 +2804,9 @@ function buildOrderDrawerListParams(): Record<string, any> | null {
if (selectedDeptIds.value.length > 0) {
params.dept_ids = selectedDeptIds.value.join(',')
}
if (f.yejiTableRowDeptIds && f.yejiTableRowDeptIds.length > 0) {
params.yeji_table_row_dept_ids = f.yejiTableRowDeptIds.join(',')
}
} else {
params.assistant_id = f.assistantId
if (f.erCenterRevisitSlot !== undefined) {
@@ -3047,28 +3193,79 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
void openLeadLinesDialog(tb, row)
}
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
{ key: 'event_time_text', label: '进线时间' },
{ key: 'reception_admin_name', label: '接待' },
{ key: 'external_contact_name', label: '客户' },
{ key: 'external_userid', label: '外部联系人ID' },
{ key: 'user_id', label: '企微成员ID' },
{ key: 'state', label: '渠道参数' },
]
function buildLeadLinesRequestParams(
ctx: { tb: YejiTable; row: YejiRow },
page: number,
pageSize: number
): Record<string, string | number> {
const { tb, row } = ctx
const p: Record<string, string | number> = {
start_date: tb.start_date,
end_date: tb.end_date,
dept_id: row.dept_id,
page,
page_size: pageSize,
}
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
return p
}
function escapeLeadLinesCsvCell(value: unknown): string {
const s = value == null ? '' : String(value)
if (/[",\n\r]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`
}
return s
}
function buildLeadLinesCsv(rows: YejiLeadLineRow[]): string {
const header = LEAD_LINES_EXPORT_COLUMNS.map((c) => escapeLeadLinesCsvCell(c.label)).join(',')
const body = rows
.map((row) => LEAD_LINES_EXPORT_COLUMNS.map((c) => escapeLeadLinesCsvCell(row[c.key])).join(','))
.join('\n')
return `\uFEFF${header}\n${body}`
}
function downloadLeadLinesCsv(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
function buildLeadLinesExportFilename(ctx: { tb: YejiTable; row: YejiRow }): string {
const { tb, row } = ctx
const safeDept = (row.dept_name || `dept${row.dept_id}`).replace(/[\\/:*?"<>|]/g, '_')
return `进线数据明细_${safeDept}_${tb.start_date}_${tb.end_date}.csv`
}
async function fetchLeadLinesPage() {
const ctx = leadLinesContext.value
if (!ctx) {
return
}
const { tb, row } = ctx
leadLinesLoading.value = true
try {
const p: Record<string, string | number> = {
start_date: tb.start_date,
end_date: tb.end_date,
dept_id: row.dept_id,
page: leadLinesPage.value,
page_size: leadLinesPageSize.value,
}
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
const res: any = await yejiStatsLeadLines(p as any)
const res: any = await yejiStatsLeadLines(
buildLeadLinesRequestParams(ctx, leadLinesPage.value, leadLinesPageSize.value) as any
)
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
leadLinesCount.value = Number(res?.count ?? 0)
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
@@ -3107,6 +3304,40 @@ function onLeadLinesPageSizeChange(size: number) {
void fetchLeadLinesPage()
}
async function exportLeadLines() {
const ctx = leadLinesContext.value
if (!ctx || leadLinesExporting.value) {
return
}
leadLinesExporting.value = true
try {
const pageSize = 100
const firstRes: any = await yejiStatsLeadLines(buildLeadLinesRequestParams(ctx, 1, pageSize) as any)
const total = Number(firstRes?.count ?? 0)
if (total <= 0) {
ElMessage.warning('暂无进线明细可导出')
return
}
const allRows: YejiLeadLineRow[] = Array.isArray(firstRes?.lists) ? [...firstRes.lists] : []
const totalPages = Math.ceil(total / pageSize)
for (let page = 2; page <= totalPages; page++) {
const res: any = await yejiStatsLeadLines(buildLeadLinesRequestParams(ctx, page, pageSize) as any)
const lists = Array.isArray(res?.lists) ? res.lists : []
allRows.push(...lists)
}
downloadLeadLinesCsv(buildLeadLinesExportFilename(ctx), buildLeadLinesCsv(allRows))
ElMessage.success(`已导出 ${allRows.length} 条进线明细`)
} catch (e: unknown) {
if (axios.isCancel(e)) {
return
}
const any = e as any
ElMessage.error(any?.msg || any?.message || '导出进线明细失败')
} finally {
leadLinesExporting.value = false
}
}
function appointmentLinesIndexMethod(index: number) {
return (appointmentLinesPage.value - 1) * appointmentLinesPageSize.value + index + 1
}
@@ -3561,6 +3792,7 @@ function formatLocalYmd(d: Date): string {
}
onMounted(async () => {
yejiDisplayTab.value = pickInitialYejiTab()
await Promise.all([loadDeptOptions(), loadChannelOptions()])
loadData()
loadTargetProgress()
@@ -3922,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 {
@@ -4832,6 +5077,18 @@ tbody tr.total-row:hover .yeji-lead-cell--link {
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
}
.yeji-leadlines-dialog__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.yeji-leadlines-dialog__head-main {
flex: 1;
min-width: 0;
}
.yeji-leadlines-dialog__pager {
margin-top: 14px;
display: flex;
@@ -0,0 +1,82 @@
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
:clearable="clearable"
:filterable="filterable"
:allow-create="allowCreate"
:default-first-option="allowCreate"
:disabled="disabled"
:loading="loading"
:class="selectClass"
:style="selectStyle"
@update:model-value="emit('update:modelValue', $event)"
@change="emit('change', $event)"
@visible-change="onVisibleChange"
>
<el-option
v-for="item in normalizedOptions"
:key="item.name"
:label="item.name"
:value="item.name"
/>
<!-- 旧记录可能存在不在当前字典内的值保留显示避免编辑时反查不到 -->
<el-option
v-if="modelValue && !hasCurrentValue"
:key="`__legacy_${modelValue}`"
:label="`${modelValue}(已停用)`"
:value="modelValue"
/>
</el-select>
</template>
<script lang="ts" setup>
interface OptionItem {
name: string
value?: string
}
const props = withDefaults(
defineProps<{
modelValue?: string
options: Array<OptionItem | string>
loading?: boolean
placeholder?: string
clearable?: boolean
filterable?: boolean
allowCreate?: boolean
disabled?: boolean
selectClass?: string
selectStyle?: string | Record<string, string>
}>(),
{
modelValue: '',
loading: false,
placeholder: '请选择自媒体来源',
clearable: true,
filterable: true,
allowCreate: false,
disabled: false,
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [value: string]
'visible-change': [visible: boolean]
}>()
const normalizedOptions = computed<OptionItem[]>(() => {
return (props.options || []).map((item) =>
typeof item === 'string' ? { name: item } : { name: item.name, value: item.value }
)
})
const hasCurrentValue = computed(() => {
return normalizedOptions.value.some((item) => item.name === props.modelValue)
})
const onVisibleChange = (visible: boolean) => {
emit('visible-change', visible)
}
</script>
@@ -0,0 +1,254 @@
<template>
<div class="personal-account-cost-page">
<el-card class="!border-none mb-4" shadow="never">
<div class="flex flex-wrap">
<div class="w-1/2 md:w-1/3">
<div class="leading-10">当前筛选天数</div>
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
</div>
<div class="w-1/2 md:w-1/3">
<div class="leading-10">累计账户消耗 ()</div>
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
</div>
</div>
</el-card>
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" inline>
<el-form-item label="日期范围">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
:data="deptOptions"
node-key="id"
:props="deptTreeProps"
:default-expand-all="true"
check-strictly
placeholder="全部部门"
clearable
filterable
class="w-[220px]"
@change="resetPage"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<media-source-select
v-model="queryParams.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="全部来源"
:allow-create="false"
select-class="w-[220px]"
@change="resetPage"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
<el-form-item class="w-[280px]" label="备注/录入人">
<el-input
v-model="queryParams.remark"
placeholder="备注 / 创建人"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<el-button v-perms="['stats.personal_account_cost/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
</div>
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="日期" prop="cost_date" min-width="120" />
<el-table-column label="自媒体来源" prop="media_source" min-width="160" show-overflow-tooltip />
<el-table-column label="账户消耗" min-width="120">
<template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="录入人" prop="creator_name" min-width="100" />
<el-table-column label="部门" prop="dept_name" min-width="140">
<template #default="{ row }">
<el-tooltip
v-if="row.dept_path"
:content="row.dept_path"
placement="top"
effect="dark"
>
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
</el-tooltip>
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else class="text-gray-400">未分配</span>
</template>
</el-table-column>
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
<el-table-column label="更新时间" prop="update_time" min-width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['stats.personal_account_cost/edit']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['stats.personal_account_cost/delete']"
type="danger"
link
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<cost-edit-popup
v-if="showEdit"
ref="editRef"
:media-source-options="mediaSourceOptions"
:media-source-loading="mediaSourceLoading"
@success="handleCostSuccess"
@close="showEdit = false"
/>
</div>
</template>
<script lang="ts" setup name="personalAccountCost">
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
import { deptAll } from '@/api/org/department'
import { usePaging } from '@/hooks/usePaging'
import { getRoutePath } from '@/router'
import feedback from '@/utils/feedback'
import CostEditPopup from './cost-edit.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
const router = useRouter()
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
const showEdit = ref(false)
const deptOptions = ref<any[]>([])
const {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
} = useMediaSourceOptions()
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
const goSelfInput = () => {
if (selfInputPath.value) {
router.push(selfInputPath.value)
}
}
const queryParams = reactive({
start_date: '',
end_date: '',
media_source: '',
dept_id: undefined as number | undefined,
remark: '',
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: personalAccountCostLists,
params: queryParams,
})
const handleReset = () => {
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.remark = ''
resetPage()
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
if (visible) {
refreshMediaSourceOptions()
}
}
const handleCostSuccess = async () => {
await refreshMediaSourceOptions()
getLists()
}
const handleAdd = async () => {
await refreshMediaSourceOptions()
showEdit.value = true
await nextTick()
editRef.value?.open('add')
}
const handleEdit = async (row: Record<string, any>) => {
showEdit.value = true
await nextTick()
editRef.value?.open('edit')
editRef.value?.setFormData(row)
}
const handleDelete = async (id: number) => {
await feedback.confirm('确定删除该账户消耗记录?')
await personalAccountCostDelete({ id })
getLists()
}
const loadDeptOptions = async () => {
try {
const res = await deptAll({ apply_data_scope: 1 })
if (Array.isArray(res)) {
deptOptions.value = res
}
} catch (e) {
deptOptions.value = []
}
}
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
})
</script>
<style scoped lang="scss">
.personal-account-cost-page {
padding: 20px;
}
.dept-cell {
cursor: help;
border-bottom: 1px dashed #c0c4cc;
}
</style>
@@ -0,0 +1,142 @@
<template>
<div class="edit-popup">
<popup
ref="popupRef"
:title="popupTitle"
:async="true"
width="520px"
@confirm="handleSubmit"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
<el-form-item label="日期" prop="cost_date">
<el-date-picker
v-model="formData.cost_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
:disabled="mode === 'edit'"
class="w-full"
/>
</el-form-item>
<el-form-item label="自媒体来源" prop="media_source">
<media-source-select
v-model="formData.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="请选择自媒体来源"
:allow-create="false"
:disabled="mode === 'edit'"
select-class="w-full"
/>
</el-form-item>
<el-form-item label="账户消耗" prop="amount">
<el-input-number
v-model="formData.amount"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
maxlength="255"
show-word-limit
placeholder="选填"
/>
</el-form-item>
</el-form>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import {
personalAccountCostAdd,
personalAccountCostEdit,
} from '@/api/self_input_stats'
import Popup from '@/components/popup/index.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import type { MediaSourceOption } from './useMediaSourceOptions'
withDefaults(
defineProps<{
mediaSourceOptions?: MediaSourceOption[]
mediaSourceLoading?: boolean
}>(),
{
mediaSourceOptions: () => [],
mediaSourceLoading: false,
}
)
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const mode = ref<'add' | 'edit'>('add')
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
const formData = reactive({
id: '',
cost_date: '',
media_source: '',
amount: 0,
remark: '',
})
const formRules = {
cost_date: [{ required: true, message: '请选择日期', trigger: ['change'] }],
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
amount: [{ required: true, message: '请输入账户消耗金额', trigger: ['blur', 'change'] }],
}
const resetForm = () => {
formData.id = ''
formData.cost_date = ''
formData.media_source = ''
formData.amount = 0
formData.remark = ''
}
const handleSubmit = async () => {
await formRef.value?.validate()
if (mode.value === 'edit') {
await personalAccountCostEdit(formData)
} else {
await personalAccountCostAdd(formData)
}
popupRef.value?.close()
emit('success')
}
const open = (type: 'add' | 'edit' = 'add') => {
mode.value = type
resetForm()
popupRef.value?.open()
}
const setFormData = (data: Record<string, any>) => {
formData.id = data.id ?? ''
formData.cost_date = data.cost_date ?? ''
formData.media_source = data.media_source ?? ''
formData.amount = Number(data.amount ?? 0)
formData.remark = data.remark ?? ''
}
const handleClose = () => {
emit('close')
}
defineExpose({
open,
setFormData,
})
</script>
+503
View File
@@ -0,0 +1,503 @@
<template>
<div class="self-input-stats-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<el-form-item label="时间范围">
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">最近30天</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="queryParams.time_type === 'custom'">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
@change="handleCustomDateChange"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
:data="deptOptions"
node-key="id"
:props="deptTreeProps"
:default-expand-all="true"
check-strictly
placeholder="全部部门"
clearable
filterable
style="width: 220px"
@change="handleQuery"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<media-source-select
v-model="queryParams.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="全部来源"
:allow-create="false"
select-style="width: 220px"
@change="handleQuery"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="stats-kpi-grid">
<div v-for="card in visibleSummaryCards" :key="card.key" class="stats-kpi-card">
<div class="stats-kpi-label">{{ card.label }}</div>
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
{{ renderMetric(card.key, overview.summary, card.type) }}
</div>
</div>
</div>
<el-card class="!border-none mt-4 stats-detail-card" shadow="never">
<template #header>
<div class="card-header">
<div class="card-header-main">
<span class="card-title">业绩明细</span>
<span class="card-hint">{{ dateRangeText }}</span>
</div>
<div class="card-header-actions">
<el-button v-perms="['stats.personal_yeji/add']" type="primary" @click="handleAddYeji">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增业绩
</el-button>
<el-button
v-if="accountCostEntryPath"
v-perms="['stats.personal_account_cost/lists']"
@click="goAccountCostEntry"
>
账户消耗录入
</el-button>
</div>
</div>
</template>
<el-table
v-loading="pager.loading"
:data="pager.lists"
border
size="large"
max-height="640"
>
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
<el-table-column label="录入人" prop="creator_name" min-width="100" />
<el-table-column label="部门" prop="dept_name" min-width="140">
<template #default="{ row }">
<el-tooltip
v-if="row.dept_path"
:content="row.dept_path"
placement="top"
effect="dark"
>
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
</el-tooltip>
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else class="text-gray-400">未分配</span>
</template>
</el-table-column>
<el-table-column
v-for="col in visibleTableColumns"
:key="col.key"
:label="col.label"
:min-width="col.minWidth || 100"
align="right"
>
<template #default="{ row }">
{{ renderMetric(col.key, row, col.type) }}
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['stats.personal_yeji/edit']"
type="primary"
link
@click="handleEditYeji(row)"
>
编辑
</el-button>
<el-button
v-perms="['stats.personal_yeji/delete']"
type="danger"
link
@click="handleDeleteYeji(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="fetchOverview" />
</div>
</el-card>
<yeji-edit-popup
v-if="showYejiEdit"
ref="yejiEditRef"
:media-source-options="mediaSourceOptions"
:media-source-loading="mediaSourceLoading"
@success="handleYejiSuccess"
@close="showYejiEdit = false"
/>
</div>
</template>
<script lang="ts" setup name="selfInputStats">
import {
getSelfInputOverview,
personalYejiDelete,
} from '@/api/self_input_stats'
import { deptAll } from '@/api/org/department'
import { usePaging } from '@/hooks/usePaging'
import { getRoutePath } from '@/router'
import feedback from '@/utils/feedback'
import MediaSourceSelect from './MediaSourceSelect.vue'
import YejiEditPopup from './yeji-edit.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
interface MetricCard {
key: string
label: string
type: MetricType
}
interface MetricColumn {
key: string
label: string
type: MetricType
minWidth?: number
}
const router = useRouter()
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
const showYejiEdit = ref(false)
const dateRange = ref<string[]>([])
const deptOptions = ref<any[]>([])
const {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
} = useMediaSourceOptions()
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const overview = reactive<Record<string, any>>({
date_range: [],
summary: {},
can_view_finance: false,
})
const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi'])
const canViewFinance = computed(() => Boolean(overview.can_view_finance))
const queryParams = reactive({
media_source: '',
dept_id: undefined as number | undefined,
time_type: 'today',
start_date: '',
end_date: '',
})
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
const goAccountCostEntry = () => {
if (accountCostEntryPath.value) {
router.push(accountCostEntryPath.value)
}
}
const fetchOverviewList = async (params: Record<string, any>) => {
if (queryParams.time_type === 'custom') {
params.start_date = dateRange.value[0] || ''
params.end_date = dateRange.value[1] || ''
}
const res = await getSelfInputOverview(params)
Object.assign(overview, res?.extend || {})
return res
}
const loadDeptOptions = async () => {
try {
const res = await deptAll({ apply_data_scope: 1 })
if (Array.isArray(res)) {
deptOptions.value = res
}
} catch (e) {
deptOptions.value = []
}
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
if (visible) {
refreshMediaSourceOptions()
}
}
const handleYejiSuccess = async () => {
await refreshMediaSourceOptions()
await fetchOverview()
}
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
})
const { pager, getLists } = usePaging({
fetchFun: fetchOverviewList,
params: queryParams,
firstLoading: true,
})
const summaryCards: MetricCard[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count' },
{ key: 'unreplied_count', label: '未回复', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
{ key: 'interview_count', label: '面诊', type: 'count' },
{ key: 'order_amount', label: '订单金额', type: 'money' },
{ key: 'account_cost', label: '账户消耗', type: 'money' },
{ key: 'total_open_rate', label: '总开口率', type: 'percent' },
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent' },
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent' },
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
{ key: 'cash_cost', label: '现金成本', type: 'money' },
{ key: 'roi', label: 'ROI', type: 'ratio' },
]
const tableColumns: MetricColumn[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count' },
{ key: 'unreplied_count', label: '未回复', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
{ key: 'interview_count', label: '面诊', type: 'count' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
{ key: 'total_open_rate', label: '总开口率', type: 'percent', minWidth: 100 },
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 120 },
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', minWidth: 110 },
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 100 },
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 100 },
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
]
const visibleSummaryCards = computed(() =>
canViewFinance.value
? summaryCards
: summaryCards.filter((card) => !FINANCE_KEYS.has(card.key))
)
const visibleTableColumns = computed(() =>
canViewFinance.value
? tableColumns
: tableColumns.filter((col) => !FINANCE_KEYS.has(col.key))
)
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
})
const fetchOverview = async () => {
try {
await getLists()
} catch (error: any) {
console.error('获取自录转化统计失败:', error)
ElMessage.error(error?.msg || '获取统计数据失败')
}
}
const handleTimeTypeChange = () => {
if (queryParams.time_type !== 'custom') {
dateRange.value = []
pager.page = 1
fetchOverview()
}
}
const handleCustomDateChange = () => {
if (dateRange.value.length === 2) {
pager.page = 1
fetchOverview()
}
}
const handleQuery = () => {
pager.page = 1
fetchOverview()
}
const handleReset = () => {
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.time_type = 'today'
dateRange.value = []
pager.page = 1
pager.size = 15
fetchOverview()
}
const renderMetric = (key: string, source: Record<string, any>, type = 'count') => {
const value = source?.[key] ?? 0
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
if (type === 'ratio') return Number(value || 0).toFixed(2)
return String(value ?? 0)
}
const handleAddYeji = async () => {
await refreshMediaSourceOptions()
showYejiEdit.value = true
await nextTick()
yejiEditRef.value?.open('add')
}
const handleEditYeji = async (row: Record<string, any>) => {
showYejiEdit.value = true
await nextTick()
yejiEditRef.value?.open('edit')
yejiEditRef.value?.setFormData(row)
}
const handleDeleteYeji = async (id: number) => {
await feedback.confirm('确定删除该业绩记录?')
await personalYejiDelete({ id })
fetchOverview()
}
onMounted(async () => {
await fetchOverview()
})
</script>
<style scoped lang="scss">
.self-input-stats-page {
padding: 20px;
}
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stats-kpi-card {
background: linear-gradient(145deg, #ffffff, #f5f8ff);
border: 1px solid #ebf1ff;
border-radius: 14px;
padding: 18px 16px;
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
}
.dept-cell {
cursor: help;
border-bottom: 1px dashed #c0c4cc;
}
.stats-kpi-label {
color: #6b7280;
font-size: 13px;
}
.stats-kpi-value {
margin-top: 12px;
font-size: 28px;
line-height: 1.1;
font-weight: 700;
color: #1f2937;
}
.stats-kpi-value.is-money {
font-size: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.card-header-main {
display: flex;
align-items: baseline;
gap: 10px;
}
.card-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.card-hint {
color: #909399;
font-size: 12px;
}
.stats-detail-card :deep(.el-card__body) {
padding-top: 0;
}
</style>
@@ -0,0 +1,63 @@
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
/**
* 广channels
*
*/
export interface MediaSourceOption {
name: string
value: string
}
export function useMediaSourceOptions() {
const mediaSourceOptions = ref<MediaSourceOption[]>([])
const mediaSourceLoading = ref(false)
const normalize = (raw: any): MediaSourceOption[] => {
if (!Array.isArray(raw)) return []
const list: MediaSourceOption[] = []
const seen = new Set<string>()
for (const item of raw) {
if (typeof item === 'string') {
const name = item.trim()
if (name && !seen.has(name)) {
seen.add(name)
list.push({ name, value: '' })
}
continue
}
if (item && typeof item === 'object') {
const name = String(item.name ?? '').trim()
if (name && !seen.has(name)) {
seen.add(name)
list.push({ name, value: String(item.value ?? '') })
}
}
}
return list
}
const loadMediaSourceOptions = async (force = false) => {
if (!force && mediaSourceOptions.value.length > 0) {
return
}
mediaSourceLoading.value = true
try {
const res = await getSelfInputMediaSourceOptions()
mediaSourceOptions.value = normalize(res)
} catch {
mediaSourceOptions.value = []
} finally {
mediaSourceLoading.value = false
}
}
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
return {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
}
}
@@ -0,0 +1,234 @@
<template>
<div class="edit-popup">
<popup
ref="popupRef"
:title="popupTitle"
:async="true"
width="640px"
@confirm="handleSubmit"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
<el-form-item label="业绩日期" prop="yeji_date">
<el-date-picker
v-model="formData.yeji_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
:disabled="mode === 'edit'"
class="w-full"
/>
</el-form-item>
<el-form-item label="自媒体来源" prop="media_source">
<media-source-select
v-model="formData.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="请选择自媒体来源"
:allow-create="false"
:disabled="mode === 'edit'"
select-class="w-full"
/>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="加粉数" prop="add_fans_count">
<el-input-number
v-model="formData.add_fans_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总开口" prop="total_open_count">
<el-input-number
v-model="formData.total_open_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="未回复" prop="unreplied_count">
<el-input-number
v-model="formData.unreplied_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="付费挂号" prop="paid_appointment_count">
<el-input-number
v-model="formData.paid_appointment_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="免费挂号" prop="free_appointment_count">
<el-input-number
v-model="formData.free_appointment_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="面诊" prop="interview_count">
<el-input-number
v-model="formData.interview_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="订单金额" prop="order_amount">
<el-input-number
v-model="formData.order_amount"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="接诊诊单" prop="completed_order_count">
<el-input-number
v-model="formData.completed_order_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
maxlength="255"
show-word-limit
placeholder="选填"
/>
</el-form-item>
</el-form>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
import Popup from '@/components/popup/index.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import type { MediaSourceOption } from './useMediaSourceOptions'
withDefaults(
defineProps<{
mediaSourceOptions?: MediaSourceOption[]
mediaSourceLoading?: boolean
}>(),
{
mediaSourceOptions: () => [],
mediaSourceLoading: false,
}
)
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const mode = ref<'add' | 'edit'>('add')
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑业绩' : '新增业绩'))
const formData = reactive({
id: '',
yeji_date: '',
media_source: '',
add_fans_count: 0,
total_open_count: 0,
unreplied_count: 0,
paid_appointment_count: 0,
free_appointment_count: 0,
interview_count: 0,
order_amount: 0,
completed_order_count: 0,
remark: '',
})
const formRules = {
yeji_date: [{ required: true, message: '请选择业绩日期', trigger: ['change'] }],
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
add_fans_count: [{ required: true, message: '请填写加粉数', trigger: ['blur', 'change'] }],
}
const resetForm = () => {
formData.id = ''
formData.yeji_date = ''
formData.media_source = ''
formData.add_fans_count = 0
formData.total_open_count = 0
formData.unreplied_count = 0
formData.paid_appointment_count = 0
formData.free_appointment_count = 0
formData.interview_count = 0
formData.order_amount = 0
formData.completed_order_count = 0
formData.remark = ''
}
const handleSubmit = async () => {
await formRef.value?.validate()
if (mode.value === 'edit') {
await personalYejiEdit(formData)
} else {
await personalYejiAdd(formData)
}
popupRef.value?.close()
emit('success')
}
const open = (type: 'add' | 'edit' = 'add') => {
mode.value = type
resetForm()
popupRef.value?.open()
}
const setFormData = (data: Record<string, any>) => {
formData.id = data.id ?? ''
formData.yeji_date = data.yeji_date ?? ''
formData.media_source = data.media_source ?? ''
formData.add_fans_count = Number(data.add_fans_count ?? 0)
formData.total_open_count = Number(data.total_open_count ?? 0)
formData.unreplied_count = Number(data.unreplied_count ?? 0)
formData.paid_appointment_count = Number(data.paid_appointment_count ?? 0)
formData.free_appointment_count = Number(data.free_appointment_count ?? 0)
formData.interview_count = Number(data.interview_count ?? 0)
formData.order_amount = Number(data.order_amount ?? 0)
formData.completed_order_count = Number(data.completed_order_count ?? 0)
formData.remark = data.remark ?? ''
}
const handleClose = () => {
emit('close')
}
defineExpose({
open,
setFormData,
})
</script>
@@ -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) }}
@@ -23,6 +23,10 @@
</div>
<div v-if="canEditDailyRecord" class="daily-matrix__toolbar-right">
<span v-if="hasPatientSelfRecord" class="daily-matrix__legend">
<span class="daily-matrix__cell-patient">自录</span>
<span class="daily-matrix__legend-text">= 患者在小程序自录</span>
</span>
<el-button type="primary" @click="openQuickAdd('blood')">+血糖</el-button>
<el-button @click="openQuickAdd('diet')">+饮食</el-button>
<el-button @click="openQuickAdd('exercise')">+运动</el-button>
@@ -30,6 +34,10 @@
<el-button @click="fetchTracking">刷新</el-button>
</div>
<div v-else class="daily-matrix__toolbar-right">
<span v-if="hasPatientSelfRecord" class="daily-matrix__legend">
<span class="daily-matrix__cell-patient">自录</span>
<span class="daily-matrix__legend-text">= 患者在小程序自录</span>
</span>
<el-button @click="fetchTracking">刷新</el-button>
</div>
</div>
@@ -57,7 +65,8 @@
:class="{
'is-clickable': isCellClickable(row.key, date),
'is-empty': !getCell(row.key, date).hasRecord,
'is-high': getCell(row.key, date).isHigh
'is-high': getCell(row.key, date).isHigh,
'is-patient-self': getCell(row.key, date).isPatientSelf && getCell(row.key, date).hasRecord
}"
@click="handleCellClick(row.key, date)"
>
@@ -82,6 +91,14 @@
<template v-else-if="getCell(row.key, date).value">
<span>{{ getCell(row.key, date).value }}</span>
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up"></span>
<el-tooltip
v-if="getCell(row.key, date).isPatientSelf"
content="患者在小程序自录的数据"
placement="top"
:show-after="120"
>
<span class="daily-matrix__cell-patient">自录</span>
</el-tooltip>
</template>
<template v-else></template>
</div>
@@ -345,6 +362,10 @@ interface BloodRecord {
western_medicine?: string
insulin?: string
remark?: string
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
source?: number
/** 合并后该日是否含有「患者自录」记录 */
has_patient_self?: boolean
[key: string]: any
}
@@ -558,6 +579,10 @@ const visibleDates = computed(() => {
return dates
})
const hasPatientSelfRecord = computed(() => {
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
})
const bloodByDate = computed(() => {
const map = new Map<string, BloodRecord>()
for (const record of bloodRecords.value) {
@@ -570,7 +595,8 @@ const bloodByDate = computed(() => {
patient_id: record.patient_id,
record_date: date,
record_time: record.record_time || '',
record_date_ts: record.record_date_ts
record_date_ts: record.record_date_ts,
has_patient_self: false
})
}
const merged = map.get(date)!
@@ -594,6 +620,10 @@ const bloodByDate = computed(() => {
;(merged as any)[field] = record[field]
}
}
// source=1
if (Number(record.source) === 1) {
merged.has_patient_self = true
}
}
return map
})
@@ -723,35 +753,40 @@ function parseTrendNumber(value: unknown): number | null {
return parsed
}
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean } {
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } {
const blood = bloodByDate.value.get(date)
const diet = dietByDate.value.get(date)
const exercise = exerciseByDate.value.get(date)
const tracking = trackingByDate.value.get(date)
const isPatientSelf = !!blood?.has_patient_self
switch (metric) {
case 'fasting':
return {
value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '',
isHigh: isHighFastingBloodSugar(blood?.fasting_blood_sugar, props.age),
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== ''
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '',
isPatientSelf
}
case 'postprandial':
return {
value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '',
isHigh: isHighPostprandialBloodSugar(blood?.postprandial_blood_sugar, props.age),
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== ''
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '',
isPatientSelf
}
case 'other':
return {
value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '',
isHigh: isHighOtherBloodSugar(blood?.other_blood_sugar, props.age),
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== ''
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== '',
isPatientSelf
}
case 'bp':
return {
value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '',
isHigh: isHighBloodPressure(blood),
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure)
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure),
isPatientSelf
}
case 'western':
return {
@@ -1115,6 +1150,12 @@ defineExpose({ refresh: fetchTracking })
color: #dc2626;
font-weight: 700;
}
&.is-patient-self {
position: relative;
background: linear-gradient(180deg, rgba(139, 92, 246, 0.04) 0%, rgba(139, 92, 246, 0.10) 100%);
border-radius: 4px;
}
}
&__cell-up {
@@ -1122,6 +1163,38 @@ defineExpose({ refresh: fetchTracking })
font-size: 13px;
}
&__cell-patient {
display: inline-block;
margin-left: 4px;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
color: #6d28d9;
background: #ede9fe;
border: 1px solid #ddd6fe;
border-radius: 999px;
line-height: 1.2;
letter-spacing: 0.5px;
white-space: nowrap;
}
&__legend {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 12px;
padding: 2px 10px 2px 6px;
background: #f8f6ff;
border: 1px dashed #ddd6fe;
border-radius: 999px;
}
&__legend-text {
font-size: 12px;
color: #6d28d9;
font-weight: 500;
}
&__todo {
margin-top: 16px;
}
+30 -1
View File
@@ -272,6 +272,24 @@
</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">
@@ -855,9 +873,18 @@ const formData = ref({
remark: '',
current_medications: '',
status: 1,
create_source: 'admin',
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 +1154,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
data.id_card = maskIdCard(data.id_card || '')
formData.value = data
formData.value.create_source = data.create_source ?? ''
const y = data.diabetes_discovery_year
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
}
@@ -1215,7 +1243,7 @@ const handleSubmit = async () => {
}
const handleViewCase = (row: any) => {
prescriptionRef.value?.openById(row.id)
prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true })
}
//
@@ -1288,6 +1316,7 @@ const handleClose = () => {
remark: '',
current_medications: '',
status: 1,
create_source: 'admin',
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
@@ -0,0 +1,162 @@
<?php
namespace app\adminapi\controller\asset;
use app\adminapi\controller\BaseAdminController;
use app\common\model\AssetResource;
use app\common\model\AssetUserResource;
use think\facade\Db;
class AssetResourceController extends BaseAdminController
{
/**
* @notes 资源列表
*/
public function lists()
{
$pageNo = $this->request->get('page_no', 1);
$pageSize = $this->request->get('page_size', 15);
$type = $this->request->get('type');
$title = $this->request->get('title', '');
$startTime = $this->request->get('start_time');
$endTime = $this->request->get('end_time');
$where = [];
if ($type) {
$where[] = ['type', '=', $type];
}
if ($title) {
$where[] = ['title', 'like', '%' . $title . '%'];
}
if ($startTime) {
$where[] = ['create_time', '>=', strtotime($startTime)];
}
if ($endTime) {
$where[] = ['create_time', '<=', strtotime($endTime) + 86399];
}
$count = AssetResource::where($where)->count();
$lists = AssetResource::with('users')
->where($where)
->order('id', 'desc')
->page($pageNo, $pageSize)
->select();
return $this->data([
'count' => $count,
'lists' => $lists,
'page_no' => $pageNo,
'page_size' => $pageSize,
]);
}
/**
* @notes 添加资源及分配账号
*/
public function add()
{
$params = $this->request->post();
if (empty($params['type']) || empty($params['title']) || empty($params['file_url'])) {
return $this->fail('请填写完整的资源信息');
}
Db::startTrans();
try {
$resource = AssetResource::create([
'type' => $params['type'],
'title' => $params['title'],
'file_url' => $params['file_url'],
'cover_url' => $params['cover_url'] ?? '',
]);
// 绑定用户
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
$userResources = [];
foreach ($params['user_ids'] as $userId) {
$userResources[] = [
'user_id' => $userId,
'resource_id' => $resource->id,
'create_time' => time(),
];
}
(new AssetUserResource())->saveAll($userResources);
}
Db::commit();
return $this->success('添加并分配成功', ['id' => $resource->id]);
} catch (\Exception $e) {
Db::rollback();
return $this->fail('操作失败: ' . $e->getMessage());
}
}
/**
* @notes 编辑资源(修改标题、关联账号)
*/
public function edit()
{
$params = $this->request->post();
$id = $params['id'] ?? 0;
if (empty($id)) {
return $this->fail('缺少参数');
}
$resource = AssetResource::find($id);
if (!$resource) {
return $this->fail('资源不存在');
}
Db::startTrans();
try {
// 更新标题
if (!empty($params['title'])) {
$resource->title = $params['title'];
$resource->save();
}
// 重新绑定用户(先删后加)
if (isset($params['user_ids'])) {
AssetUserResource::where('resource_id', $id)->delete();
$userIds = is_array($params['user_ids']) ? $params['user_ids'] : [];
if (!empty($userIds)) {
$userResources = [];
foreach ($userIds as $userId) {
$userResources[] = [
'user_id' => $userId,
'resource_id' => $id,
'create_time' => time(),
];
}
(new AssetUserResource())->saveAll($userResources);
}
}
Db::commit();
return $this->success('编辑成功');
} catch (\Exception $e) {
Db::rollback();
return $this->fail('操作失败: ' . $e->getMessage());
}
}
/**
* @notes 删除资源
*/
public function delete()
{
$id = $this->request->post('id');
if (empty($id)) {
return $this->fail('缺少参数');
}
Db::startTrans();
try {
AssetResource::destroy($id);
AssetUserResource::where('resource_id', $id)->delete();
Db::commit();
return $this->success('删除成功');
} catch (\Exception $e) {
Db::rollback();
return $this->fail('删除失败');
}
}
}
@@ -0,0 +1,116 @@
<?php
namespace app\adminapi\controller\asset;
use app\adminapi\controller\BaseAdminController;
use app\common\model\AssetUser;
class AssetUserController extends BaseAdminController
{
/**
* @notes 账号列表
*/
public function lists()
{
$pageNo = $this->request->get('page_no', 1);
$pageSize = $this->request->get('page_size', 15);
$phone = $this->request->get('phone', '');
$where = [];
if ($phone) {
$where[] = ['phone', 'like', '%' . $phone . '%'];
}
$count = AssetUser::where($where)->count();
$lists = AssetUser::where($where)
->order('id', 'desc')
->page($pageNo, $pageSize)
->select();
return $this->data([
'count' => $count,
'lists' => $lists,
'page_no' => $pageNo,
'page_size' => $pageSize,
]);
}
/**
* @notes 添加账号
*/
public function add()
{
$params = $this->request->post();
if (empty($params['phone'])) {
return $this->fail('手机号不能为空');
}
$exist = AssetUser::where('phone', $params['phone'])->find();
if ($exist) {
return $this->fail('手机号已存在');
}
// Default password 123456
$password = empty($params['password']) ? '123456' : $params['password'];
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$user = AssetUser::create([
'phone' => $params['phone'],
'password' => $passwordHash,
'status' => $params['status'] ?? 1,
]);
return $this->success('添加成功', ['id' => $user->id]);
}
/**
* @notes 编辑账号
*/
public function edit()
{
$params = $this->request->post();
if (empty($params['id'])) {
return $this->fail('缺少参数');
}
$user = AssetUser::find($params['id']);
if (!$user) {
return $this->fail('账号不存在');
}
if (!empty($params['phone']) && $params['phone'] != $user->phone) {
$exist = AssetUser::where('phone', $params['phone'])->find();
if ($exist) {
return $this->fail('手机号已存在');
}
$user->phone = $params['phone'];
}
if (!empty($params['password'])) {
$user->password = password_hash($params['password'], PASSWORD_DEFAULT);
}
if (isset($params['status'])) {
$user->status = $params['status'];
}
$user->save();
return $this->success('修改成功');
}
/**
* @notes 删除账号
*/
public function delete()
{
$id = $this->request->post('id');
if (empty($id)) {
return $this->fail('缺少参数');
}
AssetUser::destroy($id);
// 也需要删除关联的资源记录
\app\common\model\AssetUserResource::where('user_id', $id)->delete();
return $this->success('删除成功');
}
}
@@ -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
{
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\stats\PersonalAccountCostLists;
use app\adminapi\logic\stats\PersonalAccountCostLogic;
use app\adminapi\validate\stats\PersonalAccountCostValidate;
class PersonalAccountCostController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new PersonalAccountCostLists());
}
public function add()
{
$params = (new PersonalAccountCostValidate())->post()->goCheck('add');
$result = PersonalAccountCostLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
if ($result === false) {
return $this->fail(PersonalAccountCostLogic::getError());
}
return $this->success('添加成功', [], 1, 1);
}
public function edit()
{
$params = (new PersonalAccountCostValidate())->post()->goCheck('edit');
$result = PersonalAccountCostLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo);
if ($result === false) {
return $this->fail(PersonalAccountCostLogic::getError());
}
return $this->success('编辑成功', [], 1, 1);
}
public function detail()
{
$params = (new PersonalAccountCostValidate())->goCheck('detail');
$detail = PersonalAccountCostLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
if ($detail === []) {
return $this->fail('记录不存在或无权查看');
}
return $this->data($detail);
}
public function delete()
{
$params = (new PersonalAccountCostValidate())->post()->goCheck('delete');
$result = PersonalAccountCostLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PersonalAccountCostLogic::getError());
}
return $this->success('删除成功', [], 1, 1);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\stats\PersonalYejiLists;
use app\adminapi\logic\stats\PersonalYejiLogic;
use app\adminapi\validate\stats\PersonalYejiValidate;
class PersonalYejiController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new PersonalYejiLists());
}
public function add()
{
$params = (new PersonalYejiValidate())->post()->goCheck('add');
$result = PersonalYejiLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
if ($result === false) {
return $this->fail(PersonalYejiLogic::getError());
}
return $this->success('添加成功', [], 1, 1);
}
public function edit()
{
$params = (new PersonalYejiValidate())->post()->goCheck('edit');
$result = PersonalYejiLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo);
if ($result === false) {
return $this->fail(PersonalYejiLogic::getError());
}
return $this->success('编辑成功', [], 1, 1);
}
public function detail()
{
$params = (new PersonalYejiValidate())->goCheck('detail');
$detail = PersonalYejiLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
if ($detail === []) {
return $this->fail('记录不存在或无权查看');
}
return $this->data($detail);
}
public function delete()
{
$params = (new PersonalYejiValidate())->post()->goCheck('delete');
$result = PersonalYejiLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PersonalYejiLogic::getError());
}
return $this->success('删除成功', [], 1, 1);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\SelfInputLogic;
class SelfInputController extends BaseAdminController
{
public function overview()
{
$result = SelfInputLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
return $this->data($result);
}
/**
* 自媒体来源下拉:从已录入业绩/账户消耗去重提取(随录入动态变化)
*/
public function mediaSourceOptions()
{
$list = SelfInputLogic::mediaSourceOptions($this->adminId, $this->adminInfo);
return $this->data($list);
}
}
@@ -132,9 +132,55 @@ class AuthMiddleware
return true;
}
// 自录转化统计:自媒体来源下拉,复用总览或账户消耗列表权限
if ($accessUri === 'stats.selfinput/mediasourceoptions') {
$selfInputAliases = [
'stats.selfinput/overview',
'stats.self_input/overview',
'stats.personalaccountcost/lists',
'stats.personal_account_cost/lists',
];
if (count(array_intersect($selfInputAliases, $AdminUris)) > 0) {
return true;
}
}
// 处方库列表:消费者开方页/处方库页导入共用,复用开方或处方库菜单权限
if ($accessUri === 'tcm.prescriptionlibrary/lists'
&& $this->matchPrescriptionLibraryListsPermission($adminUris)) {
return true;
}
return false;
}
/**
* 处方库 lists:与开方、处方库维护菜单权限互通(避免开方页「从处方库导入」403)
*/
private function matchPrescriptionLibraryListsPermission(array $adminUris): bool
{
$aliases = [
'tcm.prescriptionlibrary/lists',
'tcm.prescription/lists',
'tcm.prescription/add',
'tcm.prescription/edit',
'tcm.prescription/detail',
'cf.prescription/lists',
'cf.prescription/add',
'cf.prescription/edit',
'cf.prescription/read',
'cf.prescription/del',
'cf.prescription/audit',
'wcf.prescription/lists',
'wcf.prescription/read',
'wcf.prescription/add',
'wcf.prescription/edit',
'wcf.prescription/delete',
];
return count(array_intersect($aliases, $adminUris)) > 0;
}
/**
* 面诊进度专用:auth.admin/lists、doctor.appointment/lists + progress_board=1,不校验菜单权限
*/
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\stats;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\stats\PersonalStatsScopeTrait;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use app\common\model\stats\PersonalAccountCost;
class PersonalAccountCostLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
use HasDataScopeFilter;
use PersonalStatsScopeTrait;
public function setSearch(): array
{
return [
'%like%' => ['media_source', 'creator_name', 'remark'],
];
}
private function baseQuery()
{
$query = PersonalAccountCost::where($this->searchWhere);
$visibleIds = $this->getDataScopeVisibleAdminIds();
$deptId = (int) ($this->params['dept_id'] ?? 0);
$finalIds = self::intersectVisibleByDept($visibleIds, $deptId);
if ($finalIds === []) {
$query->whereRaw('0 = 1');
} elseif ($finalIds !== null) {
$query->whereIn('creator_id', $finalIds);
}
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
$query->whereBetween('cost_date', [$this->params['start_date'], $this->params['end_date']]);
} elseif (!empty($this->params['start_date'])) {
$query->where('cost_date', '>=', $this->params['start_date']);
} elseif (!empty($this->params['end_date'])) {
$query->where('cost_date', '<=', $this->params['end_date']);
}
if (!empty($this->params['media_source'])) {
$query->where('media_source', trim((string) $this->params['media_source']));
}
return $query;
}
public function lists(): array
{
$rows = $this->baseQuery()
->order(['cost_date' => 'desc', 'id' => 'desc'])
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
return self::attachDeptInfoToRows($rows);
}
public function count(): int
{
return (int) $this->baseQuery()->count();
}
public function extend(): array
{
return [
'total_amount' => round((float) $this->baseQuery()->sum('amount'), 2),
'days_count' => (int) $this->baseQuery()->distinct(true)->count('cost_date'),
];
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\stats;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\stats\PersonalStatsScopeTrait;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use app\common\model\stats\PersonalYeji;
class PersonalYejiLists extends BaseAdminDataLists implements ListsSearchInterface
{
use HasDataScopeFilter;
use PersonalStatsScopeTrait;
public function setSearch(): array
{
return [
'%like%' => ['media_source', 'creator_name', 'remark'],
];
}
private function baseQuery()
{
$query = PersonalYeji::where($this->searchWhere);
$visibleIds = $this->getDataScopeVisibleAdminIds();
$deptId = (int) ($this->params['dept_id'] ?? 0);
$finalIds = self::intersectVisibleByDept($visibleIds, $deptId);
if ($finalIds === []) {
$query->whereRaw('0 = 1');
} elseif ($finalIds !== null) {
$query->whereIn('creator_id', $finalIds);
}
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
$query->whereBetween('yeji_date', [$this->params['start_date'], $this->params['end_date']]);
} elseif (!empty($this->params['start_date'])) {
$query->where('yeji_date', '>=', $this->params['start_date']);
} elseif (!empty($this->params['end_date'])) {
$query->where('yeji_date', '<=', $this->params['end_date']);
}
if (!empty($this->params['media_source'])) {
$query->where('media_source', trim((string) $this->params['media_source']));
}
if (!empty($this->params['creator_id'])) {
$query->where('creator_id', (int) $this->params['creator_id']);
}
return $query;
}
public function lists(): array
{
$rows = $this->baseQuery()
->order(['yeji_date' => 'desc', 'id' => 'desc'])
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
return self::attachDeptInfoToRows($rows);
}
public function count(): int
{
return (int) $this->baseQuery()->count();
}
}
@@ -21,7 +21,7 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
{
return [
'%like%' => ['prescription_name'],
'=' => ['is_public', 'creator_id']
'=' => ['is_public', 'creator_id', 'formula_type']
];
}
@@ -34,6 +34,16 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
return $query;
}
// 开方页导入专用参数(勿与搜索项 creator_id 混用,否则无法 OR 出他人公开模板)
$prescribingCreatorId = (int) ($this->params['prescribing_creator_id'] ?? 0);
if ($prescribingCreatorId > 0
&& PrescriptionLibraryLogic::canListLibraryForCreator($this->adminId, $this->adminInfo, $prescribingCreatorId)) {
return $query->where(function ($q) use ($prescribingCreatorId) {
$q->where('creator_id', $prescribingCreatorId)
->whereOr('is_public', 1);
});
}
return $query->where(function ($q) {
$q->where('creator_id', $this->adminId)
->whereOr('is_public', 1);
@@ -46,7 +56,7 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
public function lists(): array
{
$field = [
'id', 'prescription_name', 'herbs', 'is_public',
'id', 'prescription_name', 'formula_type', 'herbs', 'is_public',
'creator_id', 'creator_name', 'create_time', 'update_time'
];
@@ -14,10 +14,12 @@ use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use app\common\model\Order;
use app\common\model\tcm\Diagnosis;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\ExpressTracking;
use app\common\service\gancao\GancaoScmRecipelService;
@@ -106,8 +108,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyExpressKeywordFilter($query);
$this->applyServiceChannelFilter($query);
$this->applySupplyModeFilter($query);
$this->applyAuditAdminFilter($query);
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
$this->applyCreatorOrOwnPrescriptionVisibility($query);
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
if (!$this->shouldSkipCreatorOnlyForYejiDrawer()) {
$this->applyCreatorOrOwnPrescriptionVisibility($query);
}
$this->applyDataScopeForPrescriptionOrder($query);
}
// 业绩看板侧栏等:不展示履约 4/9/10,与 stats 业绩口径一致
@@ -118,6 +124,105 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
YejiStatsLogic::applyYejiDrawerChannelFilterToPrescriptionOrderQuery($query, $this->params);
}
/**
* 按下单人筛选:关联操作日志中该用户执行的处方/支付单审核记录
*/
private function applyAuditAdminFilter($query): void
{
$auditAdminId = (int) ($this->params['audit_admin_id'] ?? 0);
$keyword = trim((string) ($this->params['audit_admin_keyword'] ?? ''));
if ($auditAdminId <= 0 && $keyword === '') {
return;
}
$restricted = PrescriptionOrderLogic::isOrderPlacerAuditFilterRestricted($this->adminInfo);
if ($restricted) {
$selfId = (int) $this->adminId;
if ($auditAdminId > 0 && $auditAdminId !== $selfId) {
$query->whereRaw('0 = 1');
return;
}
if ($keyword !== '') {
$selfName = trim((string) ($this->adminInfo['name'] ?? ''));
if ($selfName === ''
|| (mb_stripos($selfName, $keyword) === false && mb_stripos($keyword, $selfName) === false)) {
$query->whereRaw('0 = 1');
return;
}
}
$auditAdminId = $selfId;
} elseif ($auditAdminId <= 0 && $keyword !== '') {
$roleIds = Config::get('project.prescription_order_placer_role_ids', [6]);
$roleIds = array_values(array_unique(array_filter(array_map(
'intval',
is_array($roleIds) ? $roleIds : []
), static fn(int $id): bool => $id > 0)));
if ($roleIds === []) {
$roleIds = [6];
}
$adminIds = Admin::alias('a')
->join('admin_role ar', 'a.id = ar.admin_id')
->whereLike('a.name', '%' . $keyword . '%')
->whereIn('ar.role_id', $roleIds)
->where('a.disable', 0)
->whereNull('a.delete_time')
->column('a.id');
$adminIds = array_values(array_filter(array_map('intval', $adminIds), static function (int $id): bool {
return $id > 0;
}));
if ($adminIds === []) {
$query->whereRaw('0 = 1');
return;
}
$this->applyAuditedByAdminIdsFilter($query, $adminIds);
return;
}
if ($auditAdminId <= 0) {
return;
}
if (!PrescriptionOrderLogic::adminHasPlacerRole($auditAdminId)) {
$query->whereRaw('0 = 1');
return;
}
$this->applyAuditedByAdminIdsFilter($query, [$auditAdminId]);
}
/**
* @param int[] $adminIds
*/
private function applyAuditedByAdminIdsFilter($query, array $adminIds): void
{
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static function (int $id): bool {
return $id > 0;
})));
if ($adminIds === []) {
$query->whereRaw('0 = 1');
return;
}
$logTbl = (new PrescriptionOrderLog())->getTable();
$poTbl = (new PrescriptionOrder())->getTable();
$actions = PrescriptionOrderLogic::prescriptionOrderAuditLogActions();
$actionIn = implode(',', array_map(static function (string $a): string {
return "'" . addslashes($a) . "'";
}, $actions));
$idIn = implode(',', $adminIds);
$query->whereExists(
"SELECT 1 FROM `{$logTbl}` l WHERE l.`prescription_order_id` = `{$poTbl}`.`id`"
. " AND l.`admin_id` IN ({$idIn}) AND l.`action` IN ({$actionIn})"
);
}
/**
* 业绩看板:仅列出二中心复诊口径下的业务订单(须同时传 assistant_id、start_time、end_timerevisit_slot=0 为全部复诊分项合计)。
* admin 维度按订单创建人 o.creator_id(与部门表复诊列、排行榜复诊列同口径)。
@@ -323,6 +428,50 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return $diagnosisPatientId > 0 && $diagnosisPatientId === $patientId;
}
/**
* 业绩看板侧栏:按部门行点合计业绩/接诊诊单(assistant_dept_id),组长等应看到组内全员订单。
*/
private function isYejiDrawerDeptPerformanceScope(): bool
{
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
return false;
}
if ((int) ($this->params['yeji_drawer_match_table_performance'] ?? 0) === 1) {
return true;
}
return isset($this->params['assistant_dept_id']) && (int) $this->params['assistant_dept_id'] > 0;
}
/**
* 业绩看板侧栏:部门业绩或复诊下钻查看数据域内其他医助订单时,跳过「仅本人可见」。
*/
private function shouldSkipCreatorOnlyForYejiDrawer(): bool
{
if ($this->isYejiDrawerDeptPerformanceScope()) {
return true;
}
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
return false;
}
if ((int) ($this->params['yeji_er_center_revisit_only'] ?? 0) !== 1) {
return false;
}
$assistantId = (int) ($this->params['assistant_id'] ?? 0);
if ($assistantId <= 0 || $assistantId === (int) $this->adminId) {
return false;
}
if (!$this->dataScopeShouldApply()) {
return true;
}
$visibleIds = $this->getDataScopeVisibleAdminIds();
if ($visibleIds === null) {
return true;
}
return in_array($assistantId, $visibleIds, true);
}
/**
* 医助角色统计:仅「订单创建人=本人」计入
*/
@@ -341,7 +490,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$query = PrescriptionOrder::where($this->searchWhereWithoutCreateTimeRange())->whereNull('delete_time');
$this->applyDoctorAssistantFilters($query);
$this->applyPatientKeywordFilter($query);
if (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
$this->applyAuditAdminFilter($query);
if ($this->shouldSkipCreatorOnlyForYejiDrawer()) {
$this->applyDataScopeForPrescriptionOrder($query);
} elseif (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
$this->applyDataScopeForPrescriptionOrder($query);
} else {
$assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
@@ -585,6 +737,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'focus_pending_pay_count' => $focus['pending_pay'],
'focus_pending_ship_count' => $focus['pending_ship'],
'focus_risk_count' => $focus['risk'],
/** 1=「下单」角色审核人筛选仅本人 */
'list_placer_audit_filter_restricted' => PrescriptionOrderLogic::isOrderPlacerAuditFilterRestricted($this->adminInfo) ? 1 : 0,
/** 下单人下拉(下单角色用户) */
'audit_admin_options' => PrescriptionOrderLogic::listAuditAdminOptions($this->adminId, $this->adminInfo),
];
$yejiConsult = $this->computeYejiDrawerConsultCountIfRequested();
if ($yejiConsult !== null) {
@@ -620,11 +776,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' => '初诊医助',
+37 -8
View File
@@ -243,7 +243,8 @@ class DeptLogic extends BaseLogic
private static function computeErCenterRevisitFullPack(
array $subtreeDeptIds,
?int $orderStartTs = null,
?int $orderEndTs = null
?int $orderEndTs = null,
?array $visibleCreatorIds = null
): array {
$emptySlots = [];
$emptyTotals = [];
@@ -284,6 +285,14 @@ class DeptLogic extends BaseLogic
/** 复用 buildAssistantCanonicalDeptInSubtree —— 该方法实际是 admin → 子树内 canonical 部门,与 admin 角色无关 */
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($creatorIds, $subtreeSet);
$creatorFlip = null;
if ($visibleCreatorIds !== null && $visibleCreatorIds !== []) {
$creatorFlip = array_flip(array_values(array_unique(array_filter(
array_map('intval', $visibleCreatorIds),
static fn (int $id): bool => $id > 0
))));
}
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */
$leafByDeptSlot = [];
/** @var array<int, array<int, int>> $byCreatorSlot 复诊按订单创建人聚合(与诊金/接诊诊单口径一致) */
@@ -293,6 +302,9 @@ class DeptLogic extends BaseLogic
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($creatorFlip !== null && ($cid <= 0 || !isset($creatorFlip[$cid]))) {
continue;
}
$deptId = $canonicalDept[$cid] ?? null;
if ($deptId === null || !isset($subtreeSet[$deptId])) {
continue;
@@ -337,14 +349,17 @@ class DeptLogic extends BaseLogic
*
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/
public static function getErCenterRevisitRollupIndexedByDept(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
public static function getErCenterRevisitRollupIndexedByDept(
?int $orderStartTs = null,
?int $orderEndTs = null,
?array $visibleCreatorIds = null
): array {
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return ['totals' => [], 'slots' => []];
}
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs, $visibleCreatorIds);
return ['totals' => $pack['totals'], 'slots' => $pack['slots']];
}
@@ -354,14 +369,17 @@ class DeptLogic extends BaseLogic
*
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/
public static function getErCenterRevisitCountsByCreator(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
public static function getErCenterRevisitCountsByCreator(
?int $orderStartTs = null,
?int $orderEndTs = null,
?array $visibleCreatorIds = null
): array {
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return ['totals' => [], 'slots' => []];
}
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs, $visibleCreatorIds);
$by = $pack['by_creator_slots'];
$totals = [];
$slots = [];
@@ -436,7 +454,8 @@ class DeptLogic extends BaseLogic
int $rootDeptId,
int $revisitSlot,
int $orderStartTs,
int $orderEndTs
int $orderEndTs,
?array $visibleCreatorIds = null
): array {
if ($rootDeptId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
return ['rows' => []];
@@ -479,6 +498,13 @@ class DeptLogic extends BaseLogic
}
}
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($creatorNeed), $erSubtreeSet);
$creatorFlip = null;
if ($visibleCreatorIds !== null && $visibleCreatorIds !== []) {
$creatorFlip = array_flip(array_values(array_unique(array_filter(
array_map('intval', $visibleCreatorIds),
static fn (int $id): bool => $id > 0
))));
}
/** @var array<int, int> $counts */
$counts = [];
foreach ($byPatient as $orders) {
@@ -492,6 +518,9 @@ class DeptLogic extends BaseLogic
if ($cid <= 0) {
continue;
}
if ($creatorFlip !== null && !isset($creatorFlip[$cid])) {
continue;
}
$canon = $canonicalDept[$cid] ?? null;
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
continue;
@@ -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),
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use app\common\model\stats\PersonalAccountCost;
class PersonalAccountCostLogic extends BaseLogic
{
use PersonalStatsScopeTrait;
public static function add(array $params, int $adminId, string $adminName): bool
{
try {
$costDate = (string) $params['cost_date'];
$mediaSource = self::normalizeMediaSource((string) ($params['media_source'] ?? ''));
if ($mediaSource === '') {
self::setError('请填写自媒体来源');
return false;
}
$dupId = self::findCostDuplicateId($adminId, $costDate, $mediaSource);
if ($dupId > 0) {
self::setError("您在 {$costDate} 已录入过【{$mediaSource}】账户消耗(记录#{$dupId}),请直接编辑该记录");
return false;
}
PersonalAccountCost::create([
'cost_date' => $costDate,
'media_source' => $mediaSource,
'amount' => round((float) ($params['amount'] ?? 0), 2),
'remark' => (string) ($params['remark'] ?? ''),
'creator_id' => $adminId,
'creator_name' => $adminName,
'updater_id' => $adminId,
'updater_name' => $adminName,
'dept_id' => self::resolvePrimaryDeptId($adminId),
]);
return true;
} catch (\Throwable $e) {
if (self::isUniqueConstraintViolation($e)) {
self::setError('该日期下该渠道的账户消耗已存在(唯一索引冲突),请刷新列表后直接编辑');
} else {
self::setError($e->getMessage());
}
return false;
}
}
public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool
{
try {
$model = PersonalAccountCost::find($params['id']);
if (!$model) {
self::setError('记录不存在');
return false;
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('无权操作该记录');
return false;
}
$model->amount = round((float) ($params['amount'] ?? 0), 2);
$model->remark = (string) ($params['remark'] ?? '');
$model->updater_id = $adminId;
$model->updater_name = $adminName;
$model->save();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function delete(int $id, int $adminId, array $adminInfo): bool
{
try {
$model = PersonalAccountCost::find($id);
if (!$model) {
self::setError('记录不存在');
return false;
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('无权操作该记录');
return false;
}
$model->delete();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function detail(int $id, int $adminId, array $adminInfo): array
{
$model = PersonalAccountCost::find($id);
if (!$model) {
return [];
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
return [];
}
return $model->toArray();
}
}
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\dept\Dept;
use app\common\model\stats\PersonalAccountCost;
use app\common\model\stats\PersonalYeji;
use app\common\service\DataScope\DataScopeService;
use think\facade\Config;
trait PersonalStatsScopeTrait
{
protected static function resolvePrimaryDeptId(int $adminId): int
{
if ($adminId <= 0) {
return 0;
}
$deptId = AdminDept::where('admin_id', $adminId)->value('dept_id');
return (int) ($deptId ?: 0);
}
/**
* @param array<int, int> $creatorIds
* @return array{0: array<int, int>, 1: array<int, string>, 2: array<int, string>}
* [adminId => deptId, deptId => name, deptId => "祖/父/当前"]
*/
protected static function loadAdminDeptMap(array $creatorIds): array
{
$creatorIds = array_values(array_unique(array_filter(array_map('intval', $creatorIds))));
if ($creatorIds === []) {
return [[], [], []];
}
$rows = AdminDept::whereIn('admin_id', $creatorIds)
->field('admin_id, dept_id')
->select()
->toArray();
$adminToDeptId = [];
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
$deptId = (int) ($row['dept_id'] ?? 0);
if ($adminId <= 0 || $deptId <= 0) {
continue;
}
if (!isset($adminToDeptId[$adminId])) {
$adminToDeptId[$adminId] = $deptId;
}
}
if ($adminToDeptId === []) {
return [[], [], []];
}
$allDeptRows = Dept::field('id, pid, name')->select()->toArray();
$deptIndex = [];
foreach ($allDeptRows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$deptIndex[$id] = [
'pid' => (int) ($row['pid'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
];
}
$deptNameMap = [];
$deptPathMap = [];
foreach (array_unique(array_values($adminToDeptId)) as $deptId) {
$deptId = (int) $deptId;
if ($deptId <= 0 || !isset($deptIndex[$deptId])) {
continue;
}
$deptNameMap[$deptId] = $deptIndex[$deptId]['name'];
$deptPathMap[$deptId] = self::resolveDeptPath($deptId, $deptIndex);
}
return [$adminToDeptId, $deptNameMap, $deptPathMap];
}
/**
* 从根节点到当前部门的完整链路(用 / 分隔)。
*
* @param array<int, array{pid: int, name: string}> $deptIndex
*/
private static function resolveDeptPath(int $deptId, array $deptIndex): string
{
$names = [];
$guard = 0;
$cursor = $deptId;
while ($cursor > 0 && isset($deptIndex[$cursor]) && $guard++ < 32) {
$node = $deptIndex[$cursor];
$name = trim($node['name']);
if ($name !== '') {
array_unshift($names, $name);
}
$cursor = $node['pid'];
}
return implode(' / ', $names);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
protected static function attachDeptInfoToRows(array $rows): array
{
if ($rows === []) {
return $rows;
}
$creatorIds = array_map(static fn (array $row): int => (int) ($row['creator_id'] ?? 0), $rows);
[$adminToDeptId, $deptNameMap, $deptPathMap] = self::loadAdminDeptMap($creatorIds);
foreach ($rows as &$row) {
$creatorId = (int) ($row['creator_id'] ?? 0);
$deptId = $adminToDeptId[$creatorId] ?? 0;
$row['dept_id'] = $deptId;
$row['dept_name'] = $deptId > 0 ? (string) ($deptNameMap[$deptId] ?? '') : '';
$row['dept_path'] = $deptId > 0 ? (string) ($deptPathMap[$deptId] ?? '') : '';
}
unset($row);
return $rows;
}
/**
* 根据 dept_id(包含其子部门)收窄可见 admin id 集合。
* visibleAdminIds 取交集。
*
* @param array<int>|null $visibleAdminIds null 表示不限
* @return array<int>|null 返回 null 表示外部条件无需附加;返回 [] 表示无可见 admin
*/
protected static function intersectVisibleByDept(?array $visibleAdminIds, int $deptId): ?array
{
if ($deptId <= 0) {
return $visibleAdminIds;
}
$deptIds = DeptLogic::getSelfAndDescendantIds($deptId);
if ($deptIds === []) {
$deptIds = [$deptId];
}
$adminIds = AdminDept::whereIn('dept_id', $deptIds)->column('admin_id');
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0)));
if ($visibleAdminIds === null) {
return $adminIds;
}
return array_values(array_intersect($visibleAdminIds, $adminIds));
}
protected static function normalizeMediaSource(string $mediaSource): string
{
return trim($mediaSource);
}
/**
* project.self_input_stats_view_all_roles 一致:超管或白名单角色可见全部录入人数据。
*/
protected static function canViewAllSelfInputStats(array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
$allow = Config::get('project.self_input_stats_view_all_roles', []);
$allow = array_map('intval', is_array($allow) ? $allow : []);
if ($allow === []) {
return false;
}
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
return count(array_intersect($myRoles, $allow)) > 0;
}
/**
* @return array<int>|null null=全部录入人;[]=无可见;int[]=可见 creator_id 集合
*/
protected static function getVisibleCreatorIds(int $adminId, array $adminInfo): ?array
{
if (self::canViewAllSelfInputStats($adminInfo)) {
return null;
}
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
protected static function assertRecordVisible(int $adminId, array $adminInfo, int $creatorId): bool
{
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
if ($visibleIds === null) {
return true;
}
return in_array($creatorId, $visibleIds, true);
}
/**
* 同一录入人 + 同一天 + 同一渠道唯一(不同录入人可同日同渠道各录一条)。
* 命中返回冲突记录 ID,未命中返回 0
*/
protected static function findYejiDuplicateId(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): int
{
$query = PersonalYeji::where('creator_id', $creatorId)
->where('yeji_date', $yejiDate)
->where('media_source', $mediaSource)
->whereNull('delete_time');
if ($excludeId > 0) {
$query->where('id', '<>', $excludeId);
}
return (int) ($query->value('id') ?? 0);
}
protected static function isYejiDuplicate(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): bool
{
return self::findYejiDuplicateId($creatorId, $yejiDate, $mediaSource, $excludeId) > 0;
}
/**
* 同一录入人 + 同一天 + 同一渠道唯一(不同录入人可同日同渠道各录一条)。
*/
protected static function findCostDuplicateId(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): int
{
$query = PersonalAccountCost::where('creator_id', $creatorId)
->where('cost_date', $costDate)
->where('media_source', $mediaSource)
->whereNull('delete_time');
if ($excludeId > 0) {
$query->where('id', '<>', $excludeId);
}
return (int) ($query->value('id') ?? 0);
}
protected static function isCostDuplicate(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): bool
{
return self::findCostDuplicateId($creatorId, $costDate, $mediaSource, $excludeId) > 0;
}
/**
* MySQL 唯一索引冲突 1062 兜底转友好提示(避免裸 SQL 异常)。
*/
protected static function isUniqueConstraintViolation(\Throwable $e): bool
{
return (int) $e->getCode() === 23000 || str_contains($e->getMessage(), '1062');
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use app\common\model\stats\PersonalYeji;
class PersonalYejiLogic extends BaseLogic
{
use PersonalStatsScopeTrait;
public static function add(array $params, int $adminId, string $adminName): bool
{
try {
$yejiDate = (string) $params['yeji_date'];
$mediaSource = self::normalizeMediaSource((string) ($params['media_source'] ?? ''));
if ($mediaSource === '') {
self::setError('请填写自媒体来源');
return false;
}
$dupId = self::findYejiDuplicateId($adminId, $yejiDate, $mediaSource);
if ($dupId > 0) {
self::setError("您在 {$yejiDate} 已录入过【{$mediaSource}】业绩(记录#{$dupId}),请直接编辑该记录");
return false;
}
PersonalYeji::create([
'yeji_date' => $yejiDate,
'media_source' => $mediaSource,
'add_fans_count' => (int) ($params['add_fans_count'] ?? 0),
'total_open_count' => (int) ($params['total_open_count'] ?? 0),
'unreplied_count' => (int) ($params['unreplied_count'] ?? 0),
'paid_appointment_count' => (int) ($params['paid_appointment_count'] ?? 0),
'free_appointment_count' => (int) ($params['free_appointment_count'] ?? 0),
'interview_count' => (int) ($params['interview_count'] ?? 0),
'order_amount' => round((float) ($params['order_amount'] ?? 0), 2),
'completed_order_count' => (int) ($params['completed_order_count'] ?? 0),
'remark' => (string) ($params['remark'] ?? ''),
'creator_id' => $adminId,
'creator_name' => $adminName,
'updater_id' => $adminId,
'updater_name' => $adminName,
'dept_id' => self::resolvePrimaryDeptId($adminId),
]);
return true;
} catch (\Throwable $e) {
if (self::isUniqueConstraintViolation($e)) {
self::setError('该日期下该渠道的业绩已存在(唯一索引冲突),请刷新列表后直接编辑');
} else {
self::setError($e->getMessage());
}
return false;
}
}
public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool
{
try {
$model = PersonalYeji::find($params['id']);
if (!$model) {
self::setError('记录不存在');
return false;
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('无权操作该记录');
return false;
}
$model->add_fans_count = (int) ($params['add_fans_count'] ?? 0);
$model->total_open_count = (int) ($params['total_open_count'] ?? 0);
$model->unreplied_count = (int) ($params['unreplied_count'] ?? 0);
$model->paid_appointment_count = (int) ($params['paid_appointment_count'] ?? 0);
$model->free_appointment_count = (int) ($params['free_appointment_count'] ?? 0);
$model->interview_count = (int) ($params['interview_count'] ?? 0);
$model->order_amount = round((float) ($params['order_amount'] ?? 0), 2);
$model->completed_order_count = (int) ($params['completed_order_count'] ?? 0);
$model->remark = (string) ($params['remark'] ?? '');
$model->updater_id = $adminId;
$model->updater_name = $adminName;
$model->save();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function delete(int $id, int $adminId, array $adminInfo): bool
{
try {
$model = PersonalYeji::find($id);
if (!$model) {
self::setError('记录不存在');
return false;
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('无权操作该记录');
return false;
}
$model->delete();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function detail(int $id, int $adminId, array $adminInfo): array
{
$model = PersonalYeji::find($id);
if (!$model) {
return [];
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
return [];
}
return $model->toArray();
}
}
@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use app\common\model\dict\DictData;
use app\common\model\stats\PersonalAccountCost;
use app\common\model\stats\PersonalYeji;
class SelfInputLogic extends BaseLogic
{
use PersonalStatsScopeTrait;
public static function overview(array $params, int $adminId, array $adminInfo): array
{
[$startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 15)));
$mediaSource = trim((string) ($params['media_source'] ?? ''));
$deptId = (int) ($params['dept_id'] ?? 0);
$effectiveAdminIds = self::resolveEffectiveAdminIds($adminId, $adminInfo, $deptId);
$yejiQuery = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
if ($mediaSource !== '') {
$yejiQuery->where('media_source', $mediaSource);
}
$count = (int) (clone $yejiQuery)->count();
$rows = (clone $yejiQuery)
->order(['yeji_date' => 'desc', 'id' => 'desc'])
->page($pageNo, $pageSize)
->select()
->toArray();
$costMap = self::loadAccountCostMap($startDate, $endDate, $effectiveAdminIds, $mediaSource);
$lists = [];
foreach ($rows as $row) {
$entity = self::normalizeYejiRow($row);
$costKey = self::buildCostKey(
(int) $entity['creator_id'],
(string) $entity['yeji_date'],
(string) $entity['media_source']
);
$entity['account_cost'] = round((float) ($costMap[$costKey] ?? 0), 2);
$lists[] = self::finalizeMetrics($entity);
}
$lists = self::attachDeptInfoToRows($lists);
$allYejiRows = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
if ($mediaSource !== '') {
$allYejiRows->where('media_source', $mediaSource);
}
$allYeji = $allYejiRows->select()->toArray();
$summaryCostMap = $costMap;
$summaryBase = self::emptyMetrics();
foreach ($allYeji as $row) {
$entity = self::normalizeYejiRow($row);
$costKey = self::buildCostKey(
(int) $entity['creator_id'],
(string) $entity['yeji_date'],
(string) $entity['media_source']
);
$entity['account_cost'] = round((float) ($summaryCostMap[$costKey] ?? 0), 2);
unset($summaryCostMap[$costKey]);
$entity = self::finalizeMetrics($entity);
$summaryBase = self::accumulateMetrics($summaryBase, $entity);
}
foreach ($summaryCostMap as $amount) {
$summaryBase['account_cost'] += round((float) $amount, 2);
}
$summary = self::finalizeMetrics($summaryBase);
$canViewFinance = self::canViewAllSelfInputStats($adminInfo);
if (!$canViewFinance) {
$summary = self::maskFinanceFields($summary);
foreach ($lists as &$item) {
$item = self::maskFinanceFields($item);
}
unset($item);
}
return [
'summary' => $summary,
'lists' => $lists,
'count' => $count,
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'summary' => $summary,
'date_range' => [$startDate, $endDate],
'can_view_finance' => $canViewFinance,
],
];
}
/**
* 财务相关字段(账户消耗 / 现金成本 / ROI):非豁免角色不可见,剔除字段避免被嗅探。
*
* @param array<string, mixed> $entity
* @return array<string, mixed>
*/
private static function maskFinanceFields(array $entity): array
{
foreach (['account_cost', 'cash_cost', 'roi'] as $key) {
unset($entity[$key]);
}
return $entity;
}
/**
* 自媒体来源选项:来自字典「推广渠道」(type_value=channels),按 sort/id 排序。
* dict_data.name 作为存储值(与历史录入兼容;保留 value 仅作展示标识)。
*
* @return array<int, array{name: string, value: string}>
*/
public static function mediaSourceOptions(int $adminId, array $adminInfo): array
{
unset($adminId, $adminInfo);
$rows = DictData::where('type_value', 'channels')
->where('status', 1)
->order(['sort' => 'desc', 'id' => 'asc'])
->field('name, value')
->select()
->toArray();
$list = [];
$seen = [];
foreach ($rows as $row) {
$name = self::normalizeMediaSource((string) ($row['name'] ?? ''));
if ($name === '' || isset($seen[$name])) {
continue;
}
$seen[$name] = true;
$list[] = [
'name' => $name,
'value' => (string) ($row['value'] ?? ''),
];
}
return $list;
}
/**
* @return array<int>|null null = 不限;[] = 无可见
*/
private static function resolveEffectiveAdminIds(int $adminId, array $adminInfo, int $deptId): ?array
{
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
return self::intersectVisibleByDept($visibleIds, $deptId);
}
/**
* @param array<int>|null $effectiveAdminIds
*/
private static function buildYejiQuery(string $startDate, string $endDate, ?array $effectiveAdminIds)
{
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
if ($effectiveAdminIds === []) {
$query->whereRaw('0 = 1');
} elseif ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
return $query;
}
/**
* @param array<int>|null $effectiveAdminIds
* @return array<string, float>
*/
private static function loadAccountCostMap(
string $startDate,
string $endDate,
?array $effectiveAdminIds,
string $mediaSource
): array {
$query = PersonalAccountCost::whereBetween('cost_date', [$startDate, $endDate]);
if ($effectiveAdminIds === []) {
return [];
}
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
if ($mediaSource !== '') {
$query->where('media_source', $mediaSource);
}
$rows = $query
->fieldRaw('creator_id, cost_date, media_source, SUM(amount) AS total_amount')
->group('creator_id, cost_date, media_source')
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$key = self::buildCostKey(
(int) $row['creator_id'],
(string) $row['cost_date'],
(string) $row['media_source']
);
$map[$key] = round((float) ($row['total_amount'] ?? 0), 2);
}
return $map;
}
private static function buildCostKey(int $creatorId, string $date, string $mediaSource): string
{
return $creatorId . '|' . $date . '|' . self::normalizeMediaSource($mediaSource);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private static function normalizeYejiRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'yeji_date' => (string) ($row['yeji_date'] ?? ''),
'media_source' => (string) ($row['media_source'] ?? ''),
'creator_id' => (int) ($row['creator_id'] ?? 0),
'creator_name' => (string) ($row['creator_name'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'add_fans_count' => (int) ($row['add_fans_count'] ?? 0),
'total_open_count' => (int) ($row['total_open_count'] ?? 0),
'unreplied_count' => (int) ($row['unreplied_count'] ?? 0),
'paid_appointment_count' => (int) ($row['paid_appointment_count'] ?? 0),
'free_appointment_count' => (int) ($row['free_appointment_count'] ?? 0),
'interview_count' => (int) ($row['interview_count'] ?? 0),
'order_amount' => round((float) ($row['order_amount'] ?? 0), 2),
'completed_order_count' => (int) ($row['completed_order_count'] ?? 0),
'account_cost' => 0.0,
];
}
/**
* @return array<string, mixed>
*/
private static function emptyMetrics(): array
{
return [
'add_fans_count' => 0,
'total_open_count' => 0,
'unreplied_count' => 0,
'paid_appointment_count' => 0,
'free_appointment_count' => 0,
'appointment_total_count' => 0,
'interview_count' => 0,
'order_amount' => 0.0,
'completed_order_count' => 0,
'account_cost' => 0.0,
];
}
/**
* @param array<string, mixed> $base
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private static function accumulateMetrics(array $base, array $row): array
{
$base['add_fans_count'] += (int) ($row['add_fans_count'] ?? 0);
$base['total_open_count'] += (int) ($row['total_open_count'] ?? 0);
$base['unreplied_count'] += (int) ($row['unreplied_count'] ?? 0);
$base['paid_appointment_count'] += (int) ($row['paid_appointment_count'] ?? 0);
$base['free_appointment_count'] += (int) ($row['free_appointment_count'] ?? 0);
$base['interview_count'] += (int) ($row['interview_count'] ?? 0);
$base['order_amount'] = round((float) $base['order_amount'] + (float) ($row['order_amount'] ?? 0), 2);
$base['completed_order_count'] += (int) ($row['completed_order_count'] ?? 0);
$base['account_cost'] = round((float) $base['account_cost'] + (float) ($row['account_cost'] ?? 0), 2);
return $base;
}
/**
* @param array<string, mixed> $entity
* @return array<string, mixed>
*/
private static function finalizeMetrics(array $entity): array
{
$paidAppointmentCount = (int) ($entity['paid_appointment_count'] ?? 0);
$freeAppointmentCount = (int) ($entity['free_appointment_count'] ?? 0);
$appointmentTotalCount = $paidAppointmentCount + $freeAppointmentCount;
$interviewCount = (int) ($entity['interview_count'] ?? 0);
$addFansCount = (int) ($entity['add_fans_count'] ?? 0);
$totalOpenCount = (int) ($entity['total_open_count'] ?? 0);
$completedOrderCount = (int) ($entity['completed_order_count'] ?? 0);
$orderAmount = round((float) ($entity['order_amount'] ?? 0), 2);
$accountCost = round((float) ($entity['account_cost'] ?? 0), 2);
$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);
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($orderAmount, $completedOrderCount);
$entity['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount);
$entity['roi'] = self::safeDivideRatio($orderAmount, $accountCost);
return $entity;
}
/**
* @return array{0: string, 1: string}
*/
private static function resolveTimeRange(array $params): array
{
$today = date('Y-m-d');
$timeType = (string) ($params['time_type'] ?? 'today');
switch ($timeType) {
case 'yesterday':
$startDate = date('Y-m-d', strtotime('-1 day'));
$endDate = $startDate;
break;
case 'week':
$startDate = date('Y-m-d', strtotime('-6 days'));
$endDate = $today;
break;
case 'month':
$startDate = date('Y-m-d', strtotime('-29 days'));
$endDate = $today;
break;
case 'custom':
$startDate = trim((string) ($params['start_date'] ?? ''));
$endDate = trim((string) ($params['end_date'] ?? ''));
if ($startDate === '' || $endDate === '') {
$startDate = $today;
$endDate = $today;
} elseif ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
break;
case 'today':
default:
$startDate = $today;
$endDate = $today;
}
return [$startDate, $endDate];
}
private static function percent(int $numerator, int $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round(($numerator / $denominator) * 100, 2);
}
private static function safeDivideMoney(float $numerator, int $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round($numerator / $denominator, 2);
}
private static function safeDivideRatio(float $numerator, float $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round($numerator / $denominator, 2);
}
}
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);
@@ -3025,7 +3034,7 @@ class DiagnosisLogic extends BaseLogic
->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()
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\cache\AdminAuthCache;
use app\common\logic\BaseLogic;
use app\common\model\auth\AdminRole;
use app\common\model\tcm\PrescriptionLibrary;
@@ -33,12 +34,64 @@ class PrescriptionLibraryLogic extends BaseLogic
return count(array_intersect($myRoles, $allowRoles)) > 0;
}
/**
* @notes 是否具备消费者/诊间开方相关菜单权限(用于处方库列表鉴权别名)
*/
public static function hasPrescriptionOperatePermission(int $adminId): bool
{
$cache = new AdminAuthCache($adminId);
$uris = $cache->getAdminUri() ?? [];
$normalized = array_map(static fn ($item) => strtolower((string) $item), $uris);
$allowed = [
'tcm.prescription/lists',
'tcm.prescription/add',
'tcm.prescription/edit',
'tcm.prescription/detail',
'cf.prescription/lists',
'cf.prescription/add',
'cf.prescription/edit',
'cf.prescription/read',
'cf.prescription/del',
'cf.prescription/audit',
'wcf.prescription/lists',
'wcf.prescription/read',
'wcf.prescription/add',
'wcf.prescription/edit',
'wcf.prescription/delete',
'tcm.prescriptionlibrary/lists',
];
return count(array_intersect($allowed, $normalized)) > 0;
}
/**
* @notes 开方页按医师 creator_id 拉取处方库:本人 / 超管角色 / 有开方菜单权限(医助代开方)
*/
public static function canListLibraryForCreator(int $adminId, array $adminInfo, int $targetCreatorId): bool
{
if ($targetCreatorId <= 0) {
return false;
}
if ($targetCreatorId === $adminId) {
return true;
}
if (self::canManageAllPrescriptions($adminId, $adminInfo)) {
return true;
}
return self::hasPrescriptionOperatePermission($adminId);
}
/**
* @notes 添加处方库
*/
public static function add(array $params): ?int
{
try {
$params['formula_type'] = in_array($params['formula_type'] ?? '', ['主方', '辅方'], true)
? $params['formula_type']
: '主方';
// 处理药材数据
if (isset($params['herbs']) && is_array($params['herbs'])) {
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
@@ -69,6 +122,12 @@ class PrescriptionLibraryLogic extends BaseLogic
return false;
}
if (isset($params['formula_type'])) {
$params['formula_type'] = in_array($params['formula_type'], ['主方', '辅方'], true)
? $params['formula_type']
: '主方';
}
// 处理药材数据
if (isset($params['herbs']) && is_array($params['herbs'])) {
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
@@ -159,6 +159,36 @@ class PrescriptionLogic
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
}
/**
* @notes 辅方用法 JSON(与主方 dosage/times/days 字段结构一致)
*/
private static function normalizeAuxUsage(array $params): ?array
{
$raw = $params['aux_usage'] ?? null;
if ($raw === null || $raw === '' || $raw === []) {
return null;
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
$raw = is_array($decoded) ? $decoded : null;
}
if (!is_array($raw)) {
return null;
}
return [
'dosage_amount' => isset($raw['dosage_amount']) && $raw['dosage_amount'] !== ''
? (float) $raw['dosage_amount']
: null,
'dosage_bag_count' => self::normalizeDosageBagCount($raw['dosage_bag_count'] ?? 1),
'need_decoction' => (int) ($raw['need_decoction'] ?? 0),
'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'] ?? '')),
];
}
private static function normalizeDosageBagCount($raw): int
{
$count = (int) $raw;
@@ -273,6 +303,7 @@ class PrescriptionLogic
'dose_unit' => $params['dose_unit'] ?? '剂',
'usage_days' => (int)($params['usage_days'] ?? 7),
'times_per_day' => (int)($params['times_per_day'] ?? 2),
'aux_usage' => self::normalizeAuxUsage($params),
'usage_instruction' => $params['usage_instruction'] ?? '水煎服一日二次',
'usage_time' => $params['usage_time'] ?? '饭前',
'usage_way' => $params['usage_way'] ?? '温水送服',
@@ -398,6 +429,9 @@ class PrescriptionLogic
'dose_unit' => $params['dose_unit'] ?? $prescription->dose_unit,
'usage_days' => (int)($params['usage_days'] ?? $prescription->usage_days),
'times_per_day' => (int)($params['times_per_day'] ?? $prescription->times_per_day ?? 2),
'aux_usage' => array_key_exists('aux_usage', $params)
? self::normalizeAuxUsage($params)
: $prescription->aux_usage,
'usage_instruction' => $params['usage_instruction'] ?? $prescription->usage_instruction,
'usage_time' => $params['usage_time'] ?? $prescription->usage_time,
'usage_way' => $params['usage_way'] ?? $prescription->usage_way,
@@ -930,6 +964,8 @@ class PrescriptionLogic
$visitNo = '1K' . str_pad((string) $diagnosisId, 8, '0', STR_PAD_LEFT);
$herbs = [];
// 与手动开方一致:保存诊单详情快照,供病历打印/查看
$caseRecord = DiagnosisLogic::detail(['id' => $diagnosisId]);
$data = [
'sn' => $sn,
@@ -954,7 +990,7 @@ class PrescriptionLogic
'tongue' => (string) ($diagnosis->tongue ?? ''),
'tongue_image' => '',
'clinical_diagnosis' => $clinical,
'case_record' => [],
'case_record' => $caseRecord ?: [],
'herbs' => $herbs,
'dose_count' => 1,
'dose_unit' => '剂',
@@ -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;
@@ -115,6 +116,123 @@ class PrescriptionOrderLogic
return self::roleIntersect($adminInfo, is_array($roles) ? $roles : []);
}
/**
* 「下单」角色:列表「审核人」筛选仅允许查本人审核过的订单(经理/管理员等豁免)
*/
public static function isOrderPlacerAuditFilterRestricted(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return false;
}
$placer = Config::get('project.prescription_order_placer_role_ids', [6]);
if (!self::roleIntersect($adminInfo, is_array($placer) ? $placer : [])) {
return false;
}
$exempt = Config::get('project.prescription_order_placer_exempt_role_ids', [3, 8]);
return !self::roleIntersect($adminInfo, is_array($exempt) ? $exempt : []);
}
/** @return string[] */
public static function prescriptionOrderAuditLogActions(): array
{
return ['audit_rx_approve', 'audit_rx_reject', 'audit_pay_approve', 'audit_pay_reject'];
}
/** 是否展示「下单人」筛选(下单 / 经理 / 管理员 / 超管) */
public static function shouldShowAuditAdminFilter(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
if (self::isOrderPlacerAuditFilterRestricted($adminInfo)) {
return true;
}
$exempt = Config::get('project.prescription_order_placer_exempt_role_ids', [3, 8]);
return self::roleIntersect($adminInfo, is_array($exempt) ? $exempt : []);
}
/**
* 下单人下拉:直接取「下单」角色下的后台用户(project.prescription_order_placer_role_ids
*
* @return array<int, array{id: int, name: string}>
*/
public static function listAuditAdminOptions(int $adminId, array $adminInfo): array
{
if ($adminId <= 0 || !self::shouldShowAuditAdminFilter($adminInfo)) {
return [];
}
$roleIds = Config::get('project.prescription_order_placer_role_ids', [6]);
$roleIds = array_values(array_unique(array_filter(array_map(
'intval',
is_array($roleIds) ? $roleIds : []
), static fn(int $id): bool => $id > 0)));
if ($roleIds === []) {
$roleIds = [6];
}
if (self::isOrderPlacerAuditFilterRestricted($adminInfo)) {
$name = trim((string) ($adminInfo['name'] ?? ''));
return [
[
'id' => $adminId,
'name' => $name !== '' ? $name : ('管理员#' . $adminId),
],
];
}
$rows = Db::name('admin')
->alias('a')
->join('admin_role ar', 'a.id = ar.admin_id')
->whereIn('ar.role_id', $roleIds)
->where('a.disable', 0)
->whereNull('a.delete_time')
->field(['a.id', 'a.name'])
->distinct(true)
->order('a.name', 'asc')
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$id = (int) ($r['id'] ?? 0);
if ($id <= 0) {
continue;
}
$name = trim((string) ($r['name'] ?? ''));
if ($name === '') {
$name = '管理员#' . $id;
}
$out[] = ['id' => $id, 'name' => $name];
}
return $out;
}
/** 指定后台账号是否拥有「下单」角色 */
public static function adminHasPlacerRole(int $adminId): bool
{
if ($adminId <= 0) {
return false;
}
$roleIds = Config::get('project.prescription_order_placer_role_ids', [6]);
$roleIds = array_values(array_unique(array_filter(array_map(
'intval',
is_array($roleIds) ? $roleIds : []
), static fn(int $id): bool => $id > 0)));
if ($roleIds === []) {
$roleIds = [6];
}
return Db::name('admin_role')
->where('admin_id', $adminId)
->whereIn('role_id', $roleIds)
->count() > 0;
}
/**
* 菜单权限:非全量角色可额外查看「关联处方开方人为本账号」的业务订单(医助代建单)
*
@@ -156,6 +274,24 @@ class PrescriptionOrderLogic
return in_array((int) $row->creator_id, $ids, true);
}
/**
* 是否已分配「处方业务订单」相关菜单/按钮权限(lists、detail、审核等任一子权限即可)
*/
public static function hasPrescriptionOrderMenuAccess(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
foreach ($perms as $p) {
if (is_string($p) && str_starts_with($p, 'tcm.prescriptionOrder/')) {
return true;
}
}
return false;
}
/**
* @param PrescriptionOrder $row
*/
@@ -164,6 +300,9 @@ class PrescriptionOrderLogic
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
return true;
}
if (self::hasPrescriptionOrderMenuAccess($adminInfo)) {
return true;
}
if ((int) $row->creator_id === $adminId) {
return true;
}
@@ -630,7 +769,7 @@ class PrescriptionOrderLogic
$rows = Order::whereIn('id', $ids)->whereNull('delete_time')
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt'])
->order('id', 'asc')
->whereIn('status', [2,5])
->whereIn('status', [2, 4, 5])
->select()
->toArray();
@@ -688,7 +827,10 @@ class PrescriptionOrderLogic
$arr['linked_pay_orders'] = self::linkedPayOrdersPayload($ids);
$sum = 0.0;
foreach ($arr['linked_pay_orders'] as $o) {
$sum += (float) ($o['amount'] ?? 0);
$st = (int) ($o['status'] ?? 0);
if ($st === 2 || $st === 5) {
$sum += (float) ($o['amount'] ?? 0);
}
}
$arr['linked_pay_paid_total'] = round($sum, 2);
$arr['deposit_min_amount'] = self::depositMinAmount();
@@ -1366,6 +1508,7 @@ class PrescriptionOrderLogic
$payload['official_urls'] = ExpressTrackService::officialUrls($num);
$payload['tracking_number'] = $num;
$payload['order_id'] = $id;
$payload['express_company_used'] = $ec;
return $payload;
@@ -2435,7 +2578,10 @@ class PrescriptionOrderLogic
$linkedPayOrders = self::linkedPayOrdersPayload($linkedPayOrderIds);
$linkedPayPaidTotal = 0.0;
foreach ($linkedPayOrders as $o) {
$linkedPayPaidTotal += (float) ($o['amount'] ?? 0);
$st = (int) ($o['status'] ?? 0);
if ($st === 2 || $st === 5) {
$linkedPayPaidTotal += (float) ($o['amount'] ?? 0);
}
}
$linkedPayPaidTotal = round($linkedPayPaidTotal, 2);
@@ -2446,7 +2592,13 @@ 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();
@@ -2462,6 +2614,9 @@ class PrescriptionOrderLogic
$logLine = '订单完成,状态变更为「' . $label . '」,实付金额更新为 ¥' . $linkedPayPaidTotal . $unassignSummary;
} else {
$logLine = '订单处理完成,状态变更为「' . $label . '」' . $unassignSummary;
if ($targetFulfillmentStatus === 10 && $refundedPayCount > 0) {
$logLine .= ',关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
}
}
self::writeLog(
$id,
@@ -2519,7 +2674,95 @@ class PrescriptionOrderLogic
}
/**
* 导出用:诊单医助在 admin_dept 中的部门路径(多部门「;」、路径内「 /
* 复诊导出「初诊医助」:同 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
*/
public static function formatAssistantDeptPathForExport(int $assistantAdminId): string
{
@@ -2539,6 +2782,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);
}
/**
* 导出用:药品形态(丸剂 / 饮片+代煎|自煎等)
*
@@ -2790,6 +3063,71 @@ 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 = [];
}
}
// 签收时间:与详情 logisticsTrace / 业绩提成同源;库内无轨迹时导出阶段补查快递100 并落库。
/** @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 = [];
}
foreach ($lists as $row) {
$oid = (int) ($row['id'] ?? 0);
if ($oid <= 0 || (int) ($signTsByPoId[$oid] ?? 0) > 0) {
continue;
}
if (trim((string) ($row['tracking_number'] ?? '')) === '') {
continue;
}
try {
$synced = ExpressTrackingService::syncSignUnixFromLogisticsForPrescriptionOrder($oid, true);
if ($synced > 0) {
$signTsByPoId[$oid] = $synced;
}
} catch (\Throwable $e) {
Log::warning('appendPrescriptionOrderListExportRows sync sign_time order ' . $oid . ': ' . $e->getMessage());
}
}
}
$firstVisitAssistantByDiag = self::batchFirstVisitAssistantNameByDiagnosis($diagIdList, $diagById);
$assistantDeptCache = [];
foreach ($lists as &$item) {
@@ -2809,25 +3147,29 @@ class PrescriptionOrderLogic
$item['export_patient_gender'] = $g === 1 ? '男' : '女';
$item['export_patient_age'] = (string) ($dg['age'] ?? '');
$item['export_patient_phone'] = (string) ($dg['phone'] ?? '');
$rxAst = \is_array($rx) ? (int) ($rx['assistant_id'] ?? 0) : 0;
$diagAst = (int) ($dg['assistant_id'] ?? 0);
$astId = self::resolveAssistantAdminIdForPrescriptionOrderRow($rxAst, $diagAst);
if ($astId > 0) {
if (!isset($assistantDeptCache[$astId])) {
$assistantDeptCache[$astId] = self::formatAssistantDeptPathForExport($astId);
}
$item['export_assistant_dept'] = $assistantDeptCache[$astId];
} else {
$item['export_assistant_dept'] = '';
}
} else {
$item['export_patient_gender'] = '';
$item['export_patient_age'] = '';
$item['export_patient_phone'] = '';
}
// 导出「医助名字 / 医助部门」:与详情 order_creator 一致,按业务订单 creator_id(非诊单二诊 assistant_id
$creatorId = (int) ($item['creator_id'] ?? 0);
$item['assistant_name'] = (string) ($item['creator_name'] ?? '');
if ($creatorId > 0) {
if (!isset($assistantDeptCache[$creatorId])) {
$assistantDeptCache[$creatorId] = self::formatAssistantDeptPathForExport($creatorId);
}
$item['export_assistant_dept'] = $assistantDeptCache[$creatorId];
} else {
$item['export_assistant_dept'] = '';
}
$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;
@@ -2846,6 +3188,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(含「测试价格」预报价写入;甘草/自营均导出)
@@ -2859,7 +3203,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);
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace app\adminapi\validate\stats;
use app\common\model\stats\PersonalAccountCost;
use app\common\validate\BaseValidate;
class PersonalAccountCostValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|checkExists',
'cost_date' => 'require|dateFormat:Y-m-d',
'media_source' => 'require|max:120',
'amount' => 'require|float|egt:0',
'remark' => 'max:255',
];
protected $message = [
'id.require' => '参数缺失',
'cost_date.require' => '请选择日期',
'cost_date.dateFormat' => '日期格式错误',
'media_source.require' => '请填写自媒体来源',
'media_source.max' => '自媒体来源不能超过120个字符',
'amount.require' => '请输入账户消耗金额',
'amount.float' => '账户消耗金额格式错误',
'amount.egt' => '账户消耗金额不能小于0',
'remark.max' => '备注不能超过255个字符',
];
public function sceneAdd()
{
return $this->remove('id', true);
}
public function sceneEdit()
{
return $this->remove('cost_date', true)->remove('media_source', true);
}
public function sceneDetail()
{
return $this->only(['id']);
}
public function sceneDelete()
{
return $this->only(['id']);
}
public function checkExists($value)
{
$model = PersonalAccountCost::find($value);
if (!$model) {
return '记录不存在';
}
return true;
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace app\adminapi\validate\stats;
use app\common\model\stats\PersonalYeji;
use app\common\validate\BaseValidate;
class PersonalYejiValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|checkExists',
'yeji_date' => 'require|dateFormat:Y-m-d',
'media_source' => 'require|max:120',
'add_fans_count' => 'require|integer|egt:0',
'total_open_count' => 'integer|egt:0',
'unreplied_count' => 'integer|egt:0',
'paid_appointment_count' => 'integer|egt:0',
'free_appointment_count' => 'integer|egt:0',
'interview_count' => 'integer|egt:0',
'order_amount' => 'float|egt:0',
'completed_order_count' => 'integer|egt:0',
'remark' => 'max:255',
];
protected $message = [
'id.require' => '参数缺失',
'yeji_date.require' => '请选择业绩日期',
'yeji_date.dateFormat' => '业绩日期格式错误',
'media_source.require' => '请填写自媒体来源',
'media_source.max' => '自媒体来源不能超过120个字符',
'add_fans_count.require' => '请填写加粉数',
'add_fans_count.integer' => '加粉数格式错误',
'add_fans_count.egt' => '加粉数不能小于0',
'remark.max' => '备注不能超过255个字符',
];
public function sceneAdd()
{
return $this->remove('id', true);
}
public function sceneEdit()
{
return $this->remove('yeji_date', true)->remove('media_source', true);
}
public function sceneDetail()
{
return $this->only(['id']);
}
public function sceneDelete()
{
return $this->only(['id']);
}
public function checkExists($value)
{
$model = PersonalYeji::find($value);
if (!$model) {
return '记录不存在';
}
return true;
}
}
@@ -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()

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