Compare commits

...
123 Commits
Author SHA1 Message Date
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
Your Name 752eddb0ba 更新 2026-05-20 16:51:12 +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 1005859404 新增 2026-05-20 15:43:47 +08:00
Your Name ea6c73c063 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-20 15:40:36 +08:00
Your Name 3e4039efb0 更新 2026-05-20 15:40:01 +08:00
long c9ad4ae2ed Update order_list.vue
增加 用法计量 数据权限
2026-05-20 14:29:22 +08:00
Your Name abf8026d45 更新 2026-05-20 10:15:26 +08:00
long 26b442b5da debug
完善自媒体渠道修改
2026-05-19 16:39:29 +08:00
long 1cb00e9fbe add assistant_performance
新增医助个人业绩
执行:add_assistant_performance_menu.sql
2026-05-19 14:41:14 +08:00
Your Name 9208e7eaaa 更新 2026-05-19 11:40:56 +08:00
Your Name a1392f9491 更新 2026-05-19 11:18:23 +08:00
Your Name 2ea0e1d54b 更新 2026-05-19 11:11:37 +08:00
Your Name d96fa6f4a1 更新 2026-05-19 10:51:44 +08:00
Your Name 0fb51e12d1 更新 2026-05-19 10:18:00 +08:00
Your Name 4f9494db0b Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 18:12:23 +08:00
Your Name 4e87b080a0 更新 2026-05-18 18:12:04 +08:00
long 2f3038aba1 Merge branch '20260518-long' 2026-05-18 18:08:46 +08:00
long 131fb7d9c7 Update edit.vue
增加:账户消耗列表-编辑可以修改自媒体
2026-05-18 18:08:35 +08:00
Your Name 762a724204 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 18:02:15 +08:00
long 8090e0aab3 Merge branch '20260518-long' 2026-05-18 17:43:25 +08:00
long 9df6330d41 update
修改未服务天数判断条件
2026-05-18 17:42:24 +08:00
Your Name fd63e23eaf 更新 2026-05-18 16:56:00 +08:00
gr c1143f4629 geng
Merge branch 'master' of https://gitee.com/v_cms/zyt
2026-05-18 16:07:03 +08:00
Your Name 149fdffd49 更新 2026-05-18 15:59:22 +08:00
Your Name 439d5a3ec9 更新 2026-05-18 15:57:09 +08:00
long 9e24d49210 Merge branch '20260518-long' 2026-05-18 14:24:52 +08:00
long 90ac43fc82 update
更新自媒体渠道标签
2026-05-18 14:24:41 +08:00
gr 8b6b709987 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 11:36:21 +08:00
Your Name fb6ca9ce85 更新 2026-05-18 11:23:01 +08:00
Your Name 08290b329e 更新 2026-05-18 11:20:24 +08:00
long 1d66f84bd3 Update PatientOrderList.vue
业务订单增加:服务套餐
2026-05-18 10:16:31 +08:00
Your Name a2d9c1a03f Merge branch 'chufan-2026-5-16' 2026-05-18 09:43:48 +08:00
Your Name d79db88349 更新 2026-05-18 09:42:11 +08:00
Your Name 9f5fb650af 新增功能 2026-05-16 16:08:54 +08:00
Your Name 15b4339c90 更新 2026-05-16 16:05:29 +08:00
gr 5ce331394d Merge branch 'master' of https://gitee.com/v_cms/zyt
你好
2026-05-14 16:27:49 +08:00
gr 800c6af7f1 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 11:31:40 +08:00
gr 876ef0f8a0 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 11:02:29 +08:00
gr fdcfa30810 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 18:15:10 +08:00
gr 4caa98af3a Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 11:27:37 +08:00
gr bb87e4fb4c Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 10:59:56 +08:00
gr 039105580c Mergt branch 'master' of https://gitee.com/v_cms/zyt 2026-05-11 17:49:51 +08:00
gr f47f431bdf 更新 2026-05-11 17:49:38 +08:00
5998 changed files with 17755 additions and 1248 deletions
+1
View File
@@ -33,3 +33,4 @@ bin-release/
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
+13
View File
@@ -15,6 +15,19 @@ 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) {
@@ -0,0 +1,309 @@
<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/index')">
<view class="menu-icon menu-icon&#45;&#45;train">
<text class="menu-icon-text">💪</text>
</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);
}
&--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: 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>
+1 -1
View File
@@ -1,5 +1,5 @@
import App from './App'
var baseUrl ='https://admin.zhenyangtang.com.cn/';
var baseUrl ='https://css.zhenyangtang.com.cn/';
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
+8 -1
View File
@@ -57,7 +57,14 @@
"optimization" : {
"subPackages" : true
},
"usingComponents" : true
"usingComponents" : true,
"plugins" : {
"WechatSI" : {
"version" : "0.3.5",
"provider" : "wx069ba97219f66d99"
}
}
},
"mp-alipay" : {
"usingComponents" : true
+36
View File
@@ -105,6 +105,42 @@
}
}
]
},
{
"root": "training",
"pages": [
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "练一练"
}
},
{
"path": "pages/metronome",
"style": {
"navigationBarTitleText": "节拍器",
"navigationBarBackgroundColor": "#f8fafc",
"navigationBarTextStyle": "black",
"backgroundColor": "#f8fafc",
"requiredBackgroundModes": ["audio"]
}
}
]
},
{
"root": "tongji",
"pages": [
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "日常记录",
"navigationBarBackgroundColor": "#0ea5a4",
"navigationBarTextStyle": "white",
"backgroundColor": "#f1f5f9",
"enablePullDownRefresh": true
}
}
]
}
],
"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;
+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: {
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* 用小米 MiMo TTS 生成训练语音素材
*
* 文档: https://platform.xiaomimimo.com/docs/en-US/usage-guide/speech-synthesis-v2.5
* 接口: POST https://api.xiaomimimo.com/v1/chat/completions
*
* 准备:
* export MIMO_API_KEY=sk-your-key-here
* (或 export XIAOMI_API_KEY=...)
*
* 运行:
* cd uniapp
* node scripts/generate-voice.mjs # 默认音色 冰糖
* node scripts/generate-voice.mjs --voice 茉莉
* node scripts/generate-voice.mjs --force # 已存在的也重新生成
*
* 输出(分包):
* training/static/voice/numbers/{1..30}.mp3
* training/static/voice/prompts/{key}.mp3
*
* 需要 Node 18+(用内置 fetch
*/
import { mkdir, writeFile, access, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Buffer } from 'node:buffer'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const ROOT = join(__dirname, '..')
const VOICE_DIR = join(ROOT, 'training/static/voice')
const NUMBERS_DIR = join(VOICE_DIR, 'numbers')
const PROMPTS_DIR = join(VOICE_DIR, 'prompts')
// ============ 参数 ============
const args = process.argv.slice(2)
const FORCE = args.includes('--force')
const VOICE = pickArg('--voice') || '冰糖'
const MODEL = pickArg('--model') || 'mimo-v2.5-tts'
const FORMAT = pickArg('--format') || 'mp3'
const API_KEY =
process.env.MIMO_API_KEY ||
process.env.XIAOMI_API_KEY ||
process.env.XIAOMI_MIMO_API_KEY
const ENDPOINT =
process.env.MIMO_ENDPOINT || 'https://api.xiaomimimo.com/v1/chat/completions'
if (!API_KEY) {
console.error('❌ 未找到 API Key')
console.error(' 请先 export MIMO_API_KEY=your-key')
process.exit(1)
}
function pickArg(name) {
const idx = args.indexOf(name)
if (idx === -1) return null
return args[idx + 1]
}
// ============ 语音内容 ============
// 数字报数:简短的"教练点数"风格
const NUMBER_STYLE =
'用简短有力的健身教练口吻报数,干净利落,节奏明快,每个数字独立清晰。'
// 口令:温柔有鼓励性的女教练
const PROMPT_STYLE =
'用温柔但有力量的女教练口吻说话,语调亲切自然,节奏适中,像在带学员训练。'
const PROMPTS = {
start: '开始',
ready: '准备',
rest: '休息一下',
'next-set': '下一组开始',
'last-rep': '最后一次',
'keep-it-up': '继续坚持',
'good-job': '很棒,训练完成',
'breathe-in': '吸气',
'breathe-out': '呼气',
}
// ============ 工具 ============
async function fileExists(p) {
try {
await access(p)
return true
} catch {
return false
}
}
async function ensureDir(d) {
await mkdir(d, { recursive: true })
}
async function tts(text, outPath, styleInstruction) {
if (!FORCE && (await fileExists(outPath))) {
const s = await stat(outPath)
if (s.size > 0) {
console.log(`[skip] ${outPath} (已存在)`)
return
}
}
const body = {
model: MODEL,
messages: [
{ role: 'user', content: styleInstruction },
{ role: 'assistant', content: text },
],
audio: { format: FORMAT, voice: VOICE },
}
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'api-key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const errText = await res.text().catch(() => '')
throw new Error(`HTTP ${res.status} ${res.statusText}: ${errText.slice(0, 300)}`)
}
const json = await res.json()
const audioBase64 = json?.choices?.[0]?.message?.audio?.data
if (!audioBase64) {
throw new Error(
`响应里找不到 audio.data 字段。完整响应: ${JSON.stringify(json).slice(0, 500)}`,
)
}
const buf = Buffer.from(audioBase64, 'base64')
await writeFile(outPath, buf)
console.log(`[ok] ${outPath} (${buf.length} bytes) → "${text}"`)
}
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
// ============ 主流程 ============
async function main() {
console.log('====== 小米 MiMo TTS 语音生成 ======')
console.log(`Endpoint : ${ENDPOINT}`)
console.log(`Model : ${MODEL}`)
console.log(`Voice : ${VOICE}`)
console.log(`Format : ${FORMAT}`)
console.log(`Force : ${FORCE}`)
console.log(`Output : ${VOICE_DIR}`)
console.log('====================================\n')
await ensureDir(NUMBERS_DIR)
await ensureDir(PROMPTS_DIR)
let successCount = 0
let failCount = 0
console.log('--- 数字 1-30 ---')
for (let n = 1; n <= 30; n++) {
try {
await tts(String(n), join(NUMBERS_DIR, `${n}.${FORMAT}`), NUMBER_STYLE)
successCount++
await sleep(120)
} catch (e) {
failCount++
console.error(`[fail] 数字 ${n}: ${e.message}`)
}
}
console.log('\n--- 口令 ---')
for (const [key, text] of Object.entries(PROMPTS)) {
try {
await tts(text, join(PROMPTS_DIR, `${key}.${FORMAT}`), PROMPT_STYLE)
successCount++
await sleep(120)
} catch (e) {
failCount++
console.error(`[fail] ${key}: ${e.message}`)
}
}
console.log('\n====================================')
console.log(`成功 ${successCount} | 失败 ${failCount}`)
console.log('====================================')
if (FORMAT !== 'mp3') {
console.log(
`\n⚠️ 当前生成格式是 ${FORMAT},但 hooks/useVoiceCoach.ts 默认引用 .mp3。`,
)
console.log(' 要么换 --format mp3 重跑,要么修改 hooks 里的 src 后缀。')
}
console.log('\n下一步:')
console.log(' 1. 准备 click 音 → training/static/audio/click.mp3')
console.log(' 2. 准备 BGM → training/static/bgm/{train-light,rest-meditation}.mp3')
console.log(' 3. 编译运行 → 进入 /training/pages/index')
}
main().catch((e) => {
console.error('\n❌ 致命错误:', e)
process.exit(1)
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
import { ref, computed, onUnmounted } from 'vue'
export interface MetronomeOptions {
initialBpm?: number
bpmMin?: number
bpmMax?: number
clickSrc?: string
accentSrc?: string
accentEvery?: number
poolSize?: number
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
constructor(
private src: string,
private size: number,
) {}
private create() {
for (let i = 0; i < this.size; i++) {
const ctx = uni.createInnerAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
this.list.push(ctx)
}
}
/**
* 预热:极小音量短暂播放,让音频文件下载/解码到内存
* 真正首次 play 不会再卡冷启动
* 注意:iOS 上 volume = 0 不一定真正解码,所以用 0.01 极小音量
*/
warmUp() {
if (this.warmedUp) return
if (this.list.length === 0) this.create()
this.list.forEach((ctx) => {
try {
ctx.volume = 0.01
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = 1
} 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 {
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
ctx.stop()
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,
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 (!clickPool) {
clickPool = new AudioPool(clickSrc, poolSize)
clickPool.warmUp()
}
if (accentSrc && !accentPool) {
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)))
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
}
/**
* 主动预加载(推荐在页面 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,
preload,
}
}
@@ -0,0 +1,213 @@
import { ref, onUnmounted } from 'vue'
/**
* 节拍器后台播放专版 - 基于 BackgroundAudioManager
*
* 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制:
* - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效
* - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API
*
* 取舍:
* - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复
* - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍)
* 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环
*
* 副作用:
* - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停)
* - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages)
* - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受
*/
export type LoopId = 'slow' | 'normal' | 'brisk'
export interface LoopPreset {
id: LoopId
bpm: number
accent: number
src: string
label: string
}
/**
* 重要:微信 BackgroundAudioManager.src 只接受 http/https 网络流,不能是包内资源
* 所以必须把音频上传到 CDN(腾讯云 COS),并在小程序后台加 downloadFile 合法域名
*
* 当前线上文件(2026-05-26 上传到 cos.ap-guangzhou):
* - loop_80bpm_2.mp3 循环音轨 慢走档
* - loop_110bpm_2.mp3 循环音轨 健走档
* - loop_130bpm_2.mp3 循环音轨 快走档
*
* 历史源文件(本地 training/static/audio/*.mp3) 已删除以减小包体积,
* 如果以后要重新合成,先从 COS 下回来再用 ffmpeg 处理
*/
export const LOOP_PRESETS: readonly LoopPreset[] = [
{
id: 'slow',
bpm: 80,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3',
label: '慢走 80 BPM',
},
{
id: 'normal',
bpm: 110,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3',
label: '健走 110 BPM',
},
{
id: 'brisk',
bpm: 130,
accent: 2,
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3',
label: '快走 130 BPM',
},
]
/**
* 锁屏控制条封面
* 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里
* 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名
*/
const COVER_URL = ''
export function useMetronomeBg() {
const isPlaying = ref<boolean>(false)
const currentLoop = ref<LoopId | null>(null)
/* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */
let bgm: any = null
/* 当前期望的 src,onEnded 时用它重新赋值实现循环 */
let desiredSrc = ''
/* 懒初始化 BackgroundAudioManager + 绑定事件
BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */
const ensureBgm = () => {
if (bgm) return bgm
const m = uni.getBackgroundAudioManager()
/* 必填 metadata,缺一会报错或不显示锁屏控制条 */
m.title = '节拍器'
m.epname = '甄养堂 · 健走'
m.singer = '健走配速'
if (COVER_URL) m.coverImgUrl = COVER_URL
m.webUrl = ''
m.onPlay(() => {
isPlaying.value = true
})
m.onPause(() => {
/* 用户从锁屏控制条点暂停,同步 UI 状态 */
isPlaying.value = false
})
m.onStop(() => {
isPlaying.value = false
currentLoop.value = null
})
/* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放
BgAudio 没有原生 loop 属性,只能用这招 */
m.onEnded(() => {
if (desiredSrc && isPlaying.value) {
try {
m.src = desiredSrc
} catch (_) {}
}
})
m.onError((err) => {
console.error('[BgAudio] error:', err)
isPlaying.value = false
uni.showToast({
title: '音频播放失败,请重试',
icon: 'none',
duration: 2000,
})
})
bgm = m
return m
}
/**
* 切到指定档位并开始播放
* 如果已经在播同一档位 → 切到 pause/play 状态
* 如果在播别的档位 → 切换 src(有 200~500ms 延迟)
*/
const playLoop = (id: LoopId) => {
const preset = LOOP_PRESETS.find((p) => p.id === id)
if (!preset) return
const m = ensureBgm()
/* 同档位再点一下 = 暂停 */
if (currentLoop.value === id && isPlaying.value) {
m.pause()
return
}
/* 切换档位或从暂停恢复 */
currentLoop.value = id
desiredSrc = preset.src
/* 重设 title 让锁屏控制条显示当前档位 */
m.title = `节拍器 · ${preset.label}`
/* 赋值 src 会自动播放(微信 API 设计如此) */
m.src = preset.src
/* isPlaying 由 onPlay 回调置 true */
}
const pause = () => {
if (bgm && isPlaying.value) {
bgm.pause()
}
}
const resume = () => {
if (bgm && !isPlaying.value && desiredSrc) {
/* 从暂停态恢复:直接 play 即可 */
try {
bgm.play()
} catch (_) {
/* 部分基础库 play() 不可用时,重赋 src */
bgm.src = desiredSrc
}
}
}
/**
* 完全停止 + 清掉锁屏控制条
* 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例
*/
const stop = () => {
if (bgm) {
try {
bgm.stop()
} catch (_) {}
}
currentLoop.value = null
desiredSrc = ''
isPlaying.value = false
}
/* hook 卸载时不主动 stop,因为用户离开节拍器页时
仍希望音乐持续(走在路上拿出手机切别的页面应该不停)
真正停止的责任在 metronome.vue 的退出按钮里 */
onUnmounted(() => {
/* 仅解绑回调? BgAudio 是全局单例,我们的回调还在,
不解会导致内存中保留无用引用,但回调里都判断了 isPlaying,
且新页面再 ensureBgm 时会覆盖回调,可接受 */
})
return {
isPlaying,
currentLoop,
playLoop,
pause,
resume,
stop,
LOOP_PRESETS,
}
}
@@ -0,0 +1,115 @@
import { ref, onUnmounted } from 'vue'
const BGM_BASE = '/training/static/bgm'
export type BgmMode = 'none' | 'training' | 'resting'
export interface UseTrainingBgmOptions {
trainingSrc?: string
restingSrc?: string
volume?: number
fadeMs?: number
}
export function useTrainingBgm(options: UseTrainingBgmOptions = {}) {
const {
trainingSrc = `${BGM_BASE}/train-light.mp3`,
restingSrc = `${BGM_BASE}/rest-meditation.mp3`,
volume = 0.6,
fadeMs = 600,
} = options
const mode = ref<BgmMode>('none')
const enabled = ref<boolean>(true)
let trainCtx: UniApp.InnerAudioContext | null = null
let restCtx: UniApp.InnerAudioContext | null = null
let fadeTimers: ReturnType<typeof setInterval>[] = []
const ensureCtx = () => {
if (!trainCtx) {
trainCtx = uni.createInnerAudioContext()
trainCtx.src = trainingSrc
trainCtx.loop = true
trainCtx.obeyMuteSwitch = false
trainCtx.volume = 0
}
if (!restCtx) {
restCtx = uni.createInnerAudioContext()
restCtx.src = restingSrc
restCtx.loop = true
restCtx.obeyMuteSwitch = false
restCtx.volume = 0
}
}
const fade = (
ctx: UniApp.InnerAudioContext,
from: number,
to: number,
duration = fadeMs,
) => {
const steps = 16
const stepMs = Math.max(16, duration / steps)
let i = 0
const t = setInterval(() => {
i++
const v = from + (to - from) * (i / steps)
ctx.volume = Math.max(0, Math.min(1, v))
if (i >= steps) {
clearInterval(t)
fadeTimers = fadeTimers.filter((x) => x !== t)
}
}, stepMs)
fadeTimers.push(t)
}
const switchTo = (target: BgmMode) => {
if (!enabled.value) return
if (mode.value === target) return
ensureCtx()
if (target === 'training' && trainCtx && restCtx) {
fade(restCtx, restCtx.volume, 0)
setTimeout(() => restCtx?.pause(), fadeMs)
trainCtx.play()
fade(trainCtx, 0, volume)
} else if (target === 'resting' && trainCtx && restCtx) {
fade(trainCtx, trainCtx.volume, 0)
setTimeout(() => trainCtx?.pause(), fadeMs)
restCtx.play()
fade(restCtx, 0, volume)
} else if (target === 'none') {
if (trainCtx) {
fade(trainCtx, trainCtx.volume, 0)
setTimeout(() => trainCtx?.pause(), fadeMs)
}
if (restCtx) {
fade(restCtx, restCtx.volume, 0)
setTimeout(() => restCtx?.pause(), fadeMs)
}
}
mode.value = target
}
const setEnabled = (v: boolean) => {
enabled.value = v
if (!v) switchTo('none')
}
onUnmounted(() => {
fadeTimers.forEach((t) => clearInterval(t))
fadeTimers = []
trainCtx?.destroy?.()
restCtx?.destroy?.()
trainCtx = null
restCtx = null
})
return {
mode,
enabled,
switchTo,
setEnabled,
}
}
@@ -0,0 +1,133 @@
import { ref, computed, onUnmounted } from 'vue'
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
export interface TrainingSessionConfig {
sets: number
reps: number
restSec: number
beatsPerRep?: number
onRepComplete?: (currentRep: number, totalReps: number) => void
onSetComplete?: (currentSet: number, totalSets: number) => void
onEnterRest?: (restSec: number) => void
onExitRest?: () => void
onDone?: () => void
onCountdownTick?: (remainingSec: number) => void
}
export function useTrainingSession() {
const phase = ref<SessionPhase>('idle')
const currentSet = ref<number>(0)
const currentRep = ref<number>(0)
const restRemainingSec = ref<number>(0)
const beatCounter = ref<number>(0)
let config: TrainingSessionConfig | null = null
let restTimer: ReturnType<typeof setInterval> | null = null
const totalReps = computed(() => config?.reps ?? 0)
const totalSets = computed(() => config?.sets ?? 0)
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
const isTraining = computed(() => phase.value === 'training')
const isResting = computed(() => phase.value === 'resting')
const isDone = computed(() => phase.value === 'done')
const start = (cfg: TrainingSessionConfig) => {
config = cfg
phase.value = 'training'
currentSet.value = 1
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
}
const onBeat = () => {
if (phase.value !== 'training' || !config) return
beatCounter.value++
if (beatCounter.value % beatsPerRep.value !== 0) return
currentRep.value++
config.onRepComplete?.(currentRep.value, totalReps.value)
if (currentRep.value >= totalReps.value) {
config.onSetComplete?.(currentSet.value, totalSets.value)
if (currentSet.value >= totalSets.value) {
phase.value = 'done'
config.onDone?.()
return
}
enterRest()
}
}
const enterRest = () => {
if (!config) return
phase.value = 'resting'
restRemainingSec.value = config.restSec
config.onEnterRest?.(config.restSec)
restTimer = setInterval(() => {
restRemainingSec.value--
config?.onCountdownTick?.(restRemainingSec.value)
if (restRemainingSec.value <= 0) {
exitRest()
}
}, 1000)
}
const exitRest = () => {
if (restTimer) {
clearInterval(restTimer)
restTimer = null
}
if (!config) return
currentSet.value++
currentRep.value = 0
beatCounter.value = 0
phase.value = 'training'
config.onExitRest?.()
}
const skipRest = () => {
if (phase.value !== 'resting') return
exitRest()
}
const stop = () => {
if (restTimer) {
clearInterval(restTimer)
restTimer = null
}
phase.value = 'idle'
currentSet.value = 0
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
}
onUnmounted(() => {
if (restTimer) clearInterval(restTimer)
})
return {
phase,
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
start,
stop,
skipRest,
onBeat,
}
}
@@ -0,0 +1,87 @@
import { onUnmounted } from 'vue'
const VOICE_BASE = '/training/static/voice'
export type VoicePromptKey =
| 'start'
| 'ready'
| 'rest'
| 'next-set'
| 'last-rep'
| 'keep-it-up'
| 'good-job'
| 'breathe-in'
| 'breathe-out'
export interface UseVoiceCoachOptions {
enabled?: boolean
volume?: number
}
export function useVoiceCoach(options: UseVoiceCoachOptions = {}) {
const { enabled = true, volume = 1 } = options
let ctx: UniApp.InnerAudioContext | null = null
let queue: string[] = []
let isPlayingQueue = false
const ensureCtx = () => {
if (ctx) return
ctx = uni.createInnerAudioContext()
ctx.volume = volume
ctx.obeyMuteSwitch = false
ctx.onEnded(() => {
playNextInQueue()
})
ctx.onError(() => {
playNextInQueue()
})
}
const playNextInQueue = () => {
if (!ctx || queue.length === 0) {
isPlayingQueue = false
return
}
const nextSrc = queue.shift()!
ctx.src = nextSrc
ctx.play()
}
const enqueue = (src: string) => {
if (!enabled) return
ensureCtx()
queue.push(src)
if (!isPlayingQueue) {
isPlayingQueue = true
playNextInQueue()
}
}
const speakNumber = (n: number) => {
if (n < 1 || n > 30) return
enqueue(`${VOICE_BASE}/numbers/${n}.mp3`)
}
const speakPrompt = (key: VoicePromptKey) => {
enqueue(`${VOICE_BASE}/prompts/${key}.mp3`)
}
const stop = () => {
queue = []
isPlayingQueue = false
ctx?.stop()
}
onUnmounted(() => {
stop()
ctx?.destroy?.()
ctx = null
})
return {
speakNumber,
speakPrompt,
stop,
}
}
@@ -0,0 +1,259 @@
<template>
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
<view class="stage">
<!-- 哑铃弯举摆臂 -->
<view v-if="animationType === 'curl'" class="curl-arm">
<view class="curl-forearm">
<view class="curl-dumbbell">{{ icon }}</view>
</view>
</view>
<!-- 哑铃推举上下移动 -->
<view v-else-if="animationType === 'press'" class="press-wrap">
<view class="press-dumbbell left">{{ icon }}</view>
<view class="press-dumbbell right">{{ icon }}</view>
</view>
<!-- 哑铃侧平举双臂张开 -->
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
<view class="sidefly-arm sidefly-left">
<view class="sidefly-dumbbell">{{ icon }}</view>
</view>
<view class="sidefly-arm sidefly-right">
<view class="sidefly-dumbbell">{{ icon }}</view>
</view>
</view>
<!-- 握力环缩放 -->
<view v-else-if="animationType === 'grip'" class="grip-ring">
<view class="grip-ring-inner">{{ icon }}</view>
</view>
<!-- 卷腹身体折叠 -->
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
<view class="crunch-body">{{ icon }}</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AnimationType } from '../exercises'
interface Props {
animationType: AnimationType
bpm: number
beatsPerRep?: number
icon?: string
isPlaying?: boolean
}
const props = withDefaults(defineProps<Props>(), {
beatsPerRep: 2,
icon: '🏋',
isPlaying: false,
})
const cssVars = computed(() => {
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
return {
'--rep-duration': `${repDurationMs}ms`,
'--anim-state': props.isPlaying ? 'running' : 'paused',
} as Record<string, string>
})
</script>
<style lang="scss" scoped>
.exercise-anim {
width: 100%;
height: 480rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
border-radius: 24rpx;
overflow: hidden;
}
.stage {
width: 320rpx;
height: 320rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* ===== 弯举 ===== */
.curl-arm {
width: 60rpx;
height: 240rpx;
background: #fbbf24;
border-radius: 30rpx;
position: relative;
.curl-forearm {
width: 60rpx;
height: 200rpx;
background: #f59e0b;
border-radius: 30rpx;
position: absolute;
bottom: 0;
left: 0;
transform-origin: bottom center;
animation: curl-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
}
.curl-dumbbell {
position: absolute;
top: -20rpx;
left: 50%;
transform: translateX(-50%);
font-size: 56rpx;
}
}
@keyframes curl-anim {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-115deg);
}
}
/* ===== 推举 ===== */
.press-wrap {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0 20rpx;
}
.press-dumbbell {
font-size: 88rpx;
animation: press-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
}
@keyframes press-anim {
0%,
100% {
transform: translateY(80rpx);
}
50% {
transform: translateY(-80rpx);
}
}
/* ===== 侧平举 ===== */
.sidefly-wrap {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.sidefly-arm {
position: absolute;
width: 140rpx;
height: 24rpx;
background: #f59e0b;
border-radius: 12rpx;
top: 50%;
transform-origin: center right;
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
&.sidefly-left {
right: 50%;
}
&.sidefly-right {
left: 50%;
transform-origin: center left;
animation-name: sidefly-right;
}
}
.sidefly-dumbbell {
position: absolute;
top: -36rpx;
font-size: 56rpx;
}
.sidefly-left .sidefly-dumbbell {
left: -20rpx;
}
.sidefly-right .sidefly-dumbbell {
right: -20rpx;
}
@keyframes sidefly-left {
0%,
100% {
transform: rotate(80deg);
}
50% {
transform: rotate(0deg);
}
}
@keyframes sidefly-right {
0%,
100% {
transform: rotate(-80deg);
}
50% {
transform: rotate(0deg);
}
}
/* ===== 握力环 ===== */
.grip-ring {
width: 240rpx;
height: 240rpx;
animation: grip-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
border: 16rpx solid #22c55e;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(34, 197, 94, 0.1);
}
.grip-ring-inner {
font-size: 80rpx;
}
@keyframes grip-anim {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(0.65);
}
}
/* ===== 卷腹 ===== */
.crunch-wrap {
width: 280rpx;
height: 280rpx;
display: flex;
align-items: flex-end;
justify-content: center;
}
.crunch-body {
font-size: 120rpx;
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
animation-play-state: var(--anim-state);
transform-origin: bottom center;
}
@keyframes crunch-anim {
0%,
100% {
transform: scaleY(1) translateY(0);
}
50% {
transform: scaleY(0.6) translateY(-10rpx);
}
}
</style>
+116
View File
@@ -0,0 +1,116 @@
export type AnimationType =
| 'curl'
| 'press'
| 'sidefly'
| 'grip'
| 'crunch'
export interface ExercisePreset {
id: string
name: string
equipment: '哑铃' | '握力环' | '健身环' | '徒手'
icon: string
animationType: AnimationType
description: string
tips: string[]
contraindication?: string
defaultBpm: number
bpmMin: number
bpmMax: number
defaultSets: number
defaultReps: number
defaultRestSec: number
beatsPerRep: number
}
export const EXERCISE_PRESETS: ExercisePreset[] = [
{
id: 'dumbbell-curl',
name: '哑铃弯举',
equipment: '哑铃',
icon: '🏋',
animationType: 'curl',
description: '锻炼肱二头肌,注意肘部固定不动',
tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'],
contraindication: '肘关节炎症急性期不宜',
defaultBpm: 60,
bpmMin: 40,
bpmMax: 100,
defaultSets: 3,
defaultReps: 12,
defaultRestSec: 60,
beatsPerRep: 2,
},
{
id: 'dumbbell-press',
name: '哑铃推举',
equipment: '哑铃',
icon: '🏋',
animationType: 'press',
description: '锻炼肩部三角肌,核心保持收紧',
tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'],
contraindication: '肩袖损伤者请咨询医生',
defaultBpm: 60,
bpmMin: 40,
bpmMax: 90,
defaultSets: 3,
defaultReps: 10,
defaultRestSec: 90,
beatsPerRep: 2,
},
{
id: 'dumbbell-sidefly',
name: '哑铃侧平举',
equipment: '哑铃',
icon: '🏋',
animationType: 'sidefly',
description: '锻炼三角肌中束,重量宜轻不宜重',
tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'],
defaultBpm: 60,
bpmMin: 40,
bpmMax: 80,
defaultSets: 3,
defaultReps: 12,
defaultRestSec: 60,
beatsPerRep: 2,
},
{
id: 'grip-ring',
name: '握力环训练',
equipment: '握力环',
icon: '🟢',
animationType: 'grip',
description: '锻炼前臂屈肌群,改善握力',
tips: ['完全握紧停顿 1 秒', '完全张开放松', '左右手交替'],
defaultBpm: 80,
bpmMin: 60,
bpmMax: 120,
defaultSets: 4,
defaultReps: 20,
defaultRestSec: 45,
beatsPerRep: 2,
},
{
id: 'crunch',
name: '集中机(仰卧卷腹)',
equipment: '健身环',
icon: '💪',
animationType: 'crunch',
description: '锻炼腹直肌,借助健身环增加阻力',
tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'],
contraindication: '腰椎间盘突出急性期不宜',
defaultBpm: 50,
bpmMin: 40,
bpmMax: 80,
defaultSets: 3,
defaultReps: 15,
defaultRestSec: 60,
beatsPerRep: 2,
},
]
export function getPresetById(id: string): ExercisePreset | undefined {
return EXERCISE_PRESETS.find((e) => e.id === id)
}
+610
View File
@@ -0,0 +1,610 @@
<template>
<view class="training-page">
<view class="header">
<view class="header-main">
<text class="title">练一练</text>
<text class="subtitle">器械动作跟练</text>
</view>
<view class="header-link" @click="goMetronome">
🎵 节拍器
</view>
</view>
<view class="equipment-tabs">
<view
v-for="eq in equipmentList"
:key="eq"
class="equipment-tab"
:class="{ active: currentEquipment === eq }"
@click="onSwitchEquipment(eq)"
>
{{ eq }}
</view>
</view>
<scroll-view scroll-x class="exercise-scroll">
<view class="exercise-list">
<view
v-for="ex in filteredExercises"
:key="ex.id"
class="exercise-card"
:class="{ active: selectedId === ex.id }"
@click="onSelect(ex.id)"
>
<text class="exercise-icon">{{ ex.icon }}</text>
<text class="exercise-name">{{ ex.name }}</text>
</view>
</view>
</scroll-view>
<view v-if="selected" class="exercise-detail">
<exercise-anim
:animation-type="selected.animationType"
:bpm="bpm"
:beats-per-rep="selected.beatsPerRep"
:icon="selected.icon"
:is-playing="metronomeIsPlaying"
/>
<view class="beat-indicator">
<view
v-for="i in 4"
:key="i"
class="beat-dot"
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
/>
</view>
<view class="meta-row">
<text class="meta-label">{{ selected.description }}</text>
</view>
<view class="settings">
<view class="setting-row">
<text class="setting-label">节奏</text>
<slider
:value="bpm"
:min="selected.bpmMin"
:max="selected.bpmMax"
:step="2"
block-size="24"
active-color="#22c55e"
@change="onBpmChange"
/>
<text class="setting-value">{{ bpm }} BPM</text>
</view>
<view class="setting-row inline">
<view class="inline-item">
<text class="setting-label">组数</text>
<view class="stepper">
<text class="step-btn" @click="adjust('sets', -1)">-</text>
<text class="step-val">{{ sets }}</text>
<text class="step-btn" @click="adjust('sets', 1)">+</text>
</view>
</view>
<view class="inline-item">
<text class="setting-label">次数</text>
<view class="stepper">
<text class="step-btn" @click="adjust('reps', -1)">-</text>
<text class="step-val">{{ reps }}</text>
<text class="step-btn" @click="adjust('reps', 1)">+</text>
</view>
</view>
<view class="inline-item">
<text class="setting-label">休息()</text>
<view class="stepper">
<text class="step-btn" @click="adjust('rest', -15)">-</text>
<text class="step-val">{{ restSec }}</text>
<text class="step-btn" @click="adjust('rest', 15)">+</text>
</view>
</view>
</view>
</view>
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
<button class="btn-primary" @click="onStart">开始训练</button>
</view>
<view v-if="isTraining" class="status-card training">
<text class="status-title">训练中</text>
<text class="status-line"> {{ currentSet }} / {{ totalSets }} </text>
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
<view class="action-row">
<button class="btn-secondary" @click="onStop">停止</button>
</view>
</view>
<view v-if="isResting" class="status-card resting">
<text class="status-title">休息中</text>
<text class="status-line big">{{ restRemainingSec }}s</text>
<view class="action-row">
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
<button class="btn-text" @click="onStop">结束训练</button>
</view>
</view>
<view v-if="isDone" class="status-card done">
<text class="status-title">训练完成 🎉</text>
<text class="status-line"> {{ totalSets }} × {{ totalReps }} </text>
<view class="action-row">
<button class="btn-primary" @click="onStart">再来一次</button>
</view>
</view>
<view class="tips-card">
<text class="tips-title">动作要领</text>
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
{{ tip }}
</text>
<text v-if="selected.contraindication" class="tips-warn">
{{ selected.contraindication }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import ExerciseAnim from './components/exercise-anim.vue'
import { EXERCISE_PRESETS, getPresetById } from './exercises'
import type { ExercisePreset } from './exercises'
import { useMetronome } from '../hooks/useMetronome'
import { useTrainingSession } from '../hooks/useTrainingSession'
import { useVoiceCoach } from '../hooks/useVoiceCoach'
import { useTrainingBgm } from '../hooks/useTrainingBgm'
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
type Equipment = (typeof equipmentList)[number]
const currentEquipment = ref<Equipment>('哑铃')
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
const filteredExercises = computed(() =>
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
)
const selected = computed<ExercisePreset | undefined>(() =>
getPresetById(selectedId.value),
)
const bpm = ref<number>(60)
const sets = ref<number>(3)
const reps = ref<number>(12)
const restSec = ref<number>(60)
watch(
selected,
(val) => {
if (!val) return
bpm.value = val.defaultBpm
sets.value = val.defaultSets
reps.value = val.defaultReps
restSec.value = val.defaultRestSec
},
{ immediate: true },
)
const voice = useVoiceCoach()
const bgm = useTrainingBgm()
const session = useTrainingSession()
const {
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
} = session
const metronome = useMetronome({
initialBpm: bpm.value,
onBeat: () => session.onBeat(),
})
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
watch(bpm, (v) => metronome.setBpm(v))
const onSwitchEquipment = (eq: Equipment) => {
if (isTraining.value || isResting.value) return
currentEquipment.value = eq
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
if (first) selectedId.value = first.id
}
const onSelect = (id: string) => {
if (isTraining.value || isResting.value) return
selectedId.value = id
}
const onBpmChange = (e: any) => {
const v = e.detail?.value ?? e
bpm.value = Number(v)
}
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
if (isTraining.value || isResting.value) return
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
}
const onStart = () => {
if (!selected.value) return
uni.setKeepScreenOn({ keepScreenOn: true })
voice.speakPrompt('start')
session.start({
sets: sets.value,
reps: reps.value,
restSec: restSec.value,
beatsPerRep: selected.value.beatsPerRep,
onRepComplete: (rep, total) => {
if (rep <= 30) voice.speakNumber(rep)
if (rep === total - 1) voice.speakPrompt('last-rep')
},
onSetComplete: (set, total) => {
if (set < total) voice.speakPrompt('keep-it-up')
},
onEnterRest: () => {
metronome.stop()
voice.speakPrompt('rest')
bgm.switchTo('resting')
},
onExitRest: () => {
voice.speakPrompt('next-set')
bgm.switchTo('training')
metronome.start()
},
onDone: () => {
metronome.stop()
bgm.switchTo('none')
voice.speakPrompt('good-job')
uni.setKeepScreenOn({ keepScreenOn: false })
},
})
bgm.switchTo('training')
metronome.start()
}
const onStop = () => {
metronome.stop()
session.stop()
bgm.switchTo('none')
uni.setKeepScreenOn({ keepScreenOn: false })
}
const onSkipRest = () => {
session.skipRest()
}
const goMetronome = () => {
uni.navigateTo({ url: '/training/pages/metronome' })
}
onHide(() => {
if (isTraining.value || isResting.value) {
onStop()
}
})
onUnmounted(() => {
uni.setKeepScreenOn({ keepScreenOn: false })
})
</script>
<style lang="scss" scoped>
.training-page {
min-height: 100vh;
background: #f9fafb;
padding: 32rpx 24rpx 80rpx;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.header-main {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
font-size: 44rpx;
font-weight: 700;
color: #111827;
}
.subtitle {
font-size: 26rpx;
color: #6b7280;
margin-top: 6rpx;
}
.header-link {
padding: 12rpx 24rpx;
background: #f3f4f6;
border-radius: 24rpx;
font-size: 24rpx;
color: #4b5563;
&:active {
background: #e5e7eb;
}
}
}
.equipment-tabs {
display: flex;
gap: 16rpx;
margin-bottom: 24rpx;
overflow-x: auto;
.equipment-tab {
flex-shrink: 0;
padding: 14rpx 32rpx;
background: #fff;
border-radius: 32rpx;
font-size: 28rpx;
color: #4b5563;
border: 2rpx solid transparent;
&.active {
background: #22c55e;
color: #fff;
border-color: #22c55e;
}
}
}
.exercise-scroll {
width: 100%;
margin-bottom: 24rpx;
}
.exercise-list {
display: flex;
gap: 16rpx;
padding-right: 16rpx;
}
.exercise-card {
flex-shrink: 0;
width: 160rpx;
padding: 20rpx 12rpx;
background: #fff;
border-radius: 16rpx;
text-align: center;
border: 2rpx solid transparent;
&.active {
border-color: #22c55e;
background: #f0fdf4;
}
.exercise-icon {
display: block;
font-size: 56rpx;
margin-bottom: 8rpx;
}
.exercise-name {
font-size: 24rpx;
color: #374151;
}
}
.exercise-detail {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.beat-indicator {
display: flex;
justify-content: center;
gap: 16rpx;
padding: 8rpx 0;
.beat-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #d1d5db;
transition: transform 0.15s, background 0.15s;
&.active {
background: #22c55e;
transform: scale(1.6);
}
}
}
.meta-row {
.meta-label {
font-size: 26rpx;
color: #6b7280;
text-align: center;
display: block;
}
}
.settings {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.setting-row {
display: flex;
align-items: center;
gap: 16rpx;
.setting-label {
font-size: 26rpx;
color: #4b5563;
min-width: 80rpx;
}
.setting-value {
font-size: 26rpx;
color: #22c55e;
font-weight: 600;
min-width: 120rpx;
text-align: right;
}
slider {
flex: 1;
}
&.inline {
justify-content: space-between;
gap: 8rpx;
}
}
.inline-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
}
.stepper {
display: flex;
align-items: center;
gap: 12rpx;
.step-btn {
width: 48rpx;
height: 48rpx;
line-height: 48rpx;
text-align: center;
background: #f3f4f6;
border-radius: 24rpx;
font-size: 32rpx;
color: #4b5563;
}
.step-val {
font-size: 30rpx;
font-weight: 600;
color: #111827;
min-width: 56rpx;
text-align: center;
}
}
.action-row {
display: flex;
gap: 16rpx;
justify-content: center;
button {
flex: 1;
max-width: 320rpx;
}
}
.btn-primary {
background: #22c55e;
color: #fff;
border-radius: 999rpx;
font-size: 30rpx;
height: 88rpx;
line-height: 88rpx;
border: none;
}
.btn-secondary {
background: #fff;
color: #22c55e;
border: 2rpx solid #22c55e;
border-radius: 999rpx;
font-size: 28rpx;
height: 80rpx;
line-height: 76rpx;
}
.btn-text {
background: transparent;
color: #ef4444;
font-size: 26rpx;
height: 80rpx;
line-height: 80rpx;
border: none;
}
.status-card {
background: #fff;
border-radius: 20rpx;
padding: 32rpx 24rpx;
text-align: center;
display: flex;
flex-direction: column;
gap: 12rpx;
align-items: center;
&.training {
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
}
&.resting {
background: linear-gradient(135deg, #fef3c7, #fef9c3);
}
&.done {
background: linear-gradient(135deg, #dbeafe, #ede9fe);
}
.status-title {
font-size: 30rpx;
color: #4b5563;
font-weight: 600;
}
.status-line {
font-size: 28rpx;
color: #6b7280;
&.big {
font-size: 64rpx;
color: #111827;
font-weight: 700;
}
}
}
.tips-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
.tips-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
margin-bottom: 8rpx;
}
.tips-item {
font-size: 26rpx;
color: #4b5563;
line-height: 1.6;
}
.tips-warn {
font-size: 26rpx;
color: #ef4444;
margin-top: 12rpx;
line-height: 1.6;
}
}
</style>
@@ -0,0 +1,735 @@
<template>
<view class="page" :style="rootStyle">
<!-- ===== 主舞台:节拍器圆 ===== -->
<view class="stage" @click="onCenterTap">
<view class="ring r1" :class="{ playing: isPlaying }" />
<view class="ring r2" :class="{ playing: isPlaying }" />
<view class="core" :class="{ playing: isPlaying }">
<text class="bpm-num">{{ currentBpm }}</text>
<text class="bpm-label">BPM</text>
<view class="ctrl">
<view v-if="isPlaying" class="icon-pause">
<view class="bar" />
<view class="bar" />
</view>
<view v-else class="icon-play" />
</view>
</view>
</view>
<!-- ===== 跑步小人(矩形完整显示,跟白底融合) ===== -->
<view class="walker">
<view class="walker-box">
<video
id="runner-video"
class="runner-video"
:src="RUNNER_VIDEO_URL"
:muted="true"
:loop="true"
:autoplay="true"
:show-controls="false"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-center-play-btn="false"
:enable-progress-gesture="false"
:show-mute-btn="false"
:picture-in-picture-mode="[]"
object-fit="contain"
@error="onVideoError"
@loadedmetadata="onVideoLoaded"
/>
<!-- #ifdef MP-WEIXIN -->
<cover-view class="pip-mask">&nbsp;</cover-view>
<!-- #endif -->
</view>
</view>
<!-- ===== 推荐档位(3 1) ===== -->
<view class="presets">
<view
v-for="p in LOOP_PRESETS"
:key="p.id"
class="preset"
:class="{ active: mode === 'preset' && currentLoop === p.id }"
@click="onPresetTap(p.id)"
>
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
<text class="preset-name">{{ presetName(p.id) }}</text>
<text class="preset-bpm">{{ p.bpm }} BPM</text>
<text class="preset-desc">{{ presetDesc(p.id) }}</text>
</view>
</view>
<!-- ===== 自定义节奏(高级折叠区) ===== -->
<view v-if="customExpanded" class="custom-panel">
<view class="custom-warn">
<text class="warn-dot"></text>
<text class="warn-text">前台模式 · 切后台会暂停</text>
</view>
<!-- BPM 微调 -->
<view class="row">
<text class="row-label">BPM</text>
<view class="bpm-stepper">
<view class="step-btn" @click="onBpmDelta(-5)">5</view>
<view class="step-btn" @click="onBpmDelta(-1)">1</view>
<view class="step-val">{{ customBpm }}</view>
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
</view>
</view>
<!-- 拍号 -->
<view class="row">
<text class="row-label">拍号</text>
<view class="meter-tabs">
<view
v-for="n in [2, 3, 4]"
:key="n"
class="meter-tab"
:class="{ active: customAccent === n }"
@click="onAccentSelect(n)"
>{{ n }}/4</view>
</view>
</view>
</view>
<!-- 底部行:自定义入口 + 提示 -->
<view class="bottom-row">
<text class="bottom-link" @click="onToggleCustom">
{{ customExpanded ? ' 收起自定义' : ' 自定义节奏' }}
</text>
<text class="bottom-hint" v-if="mode === 'preset'">🔒 锁屏可继续播放</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
import { useMetronome } from '../hooks/useMetronome'
/**
* 跑步动画视频(腾讯云 COS CDN)
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
* 否则正式发布版会被拦截(开发版默认不校验)
*/
const RUNNER_VIDEO_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
const VIDEO_BASE_BPM = 124
/* ============================================================
* 双引擎设计
* ------------------------------------------------------------
* - mode='preset': 用 BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
* - mode='custom': 用 InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
* - 切模式时停掉对侧引擎,避免双声道叠加
* ============================================================ */
type Mode = 'preset' | 'custom'
const mode = ref<Mode>('preset')
const customExpanded = ref<boolean>(false)
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
const CLICK_WOOD_URL =
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
const bg = useMetronomeBg()
const fg = useMetronome({
initialBpm: 100,
accentEvery: 2,
clickSrc: CLICK_WOOD_URL,
accentSrc: CLICK_WOOD_URL,
poolSize: 4,
})
const { LOOP_PRESETS } = bg
const currentLoop = bg.currentLoop
const customBpm = computed(() => fg.bpm.value)
const customAccent = computed(() => fg.accentEvery.value)
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
const isPlaying = computed(() =>
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
)
const currentBpm = computed(() => {
if (mode.value === 'preset') {
const p = LOOP_PRESETS.find((p) => p.id === currentLoop.value)
return p?.bpm ?? 110
}
return fg.bpm.value
})
const intervalMs = computed(() => 60000 / currentBpm.value)
/* ============================================================
* 档位元数据
* ============================================================ */
const presetIcon = (id: LoopId) =>
id === 'slow' ? '🚶' : id === 'normal' ? '🏃‍♀️' : '🏃'
const presetName = (id: LoopId) =>
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
const presetDesc = (id: LoopId) =>
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
/* ============================================================
* 中央圆按钮:在两个模式下分别工作
* ============================================================ */
const onCenterTap = () => {
if (mode.value === 'preset') {
if (!currentLoop.value) {
bg.playLoop('normal')
uni.setKeepScreenOn({ keepScreenOn: true })
return
}
if (bg.isPlaying.value) {
bg.pause()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
bg.resume()
uni.setKeepScreenOn({ keepScreenOn: true })
}
} else {
/* custom 模式 */
if (fg.isPlaying.value) {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
} else {
fg.start()
uni.setKeepScreenOn({ keepScreenOn: true })
}
}
}
/* ============================================================
* 档位卡片:点了 → 切回 preset 模式 + 停掉 custom
* ============================================================ */
const onPresetTap = (id: LoopId) => {
if (mode.value === 'custom') {
fg.stop()
mode.value = 'preset'
customExpanded.value = false
}
bg.playLoop(id)
uni.setKeepScreenOn({ keepScreenOn: true })
}
/* ============================================================
* 自定义折叠区切换
* ============================================================ */
const onToggleCustom = () => {
if (!customExpanded.value) {
/* 展开:进入 custom 模式 + 停掉 preset */
if (bg.isPlaying.value || currentLoop.value) {
bg.stop()
}
mode.value = 'custom'
/* 把当前显示 BPM 带过去当起始值 */
const seed =
LOOP_PRESETS.find((p) => p.id === (currentLoop.value ?? 'normal'))?.bpm ?? 100
fg.setBpm(seed)
fg.setAccentEvery(2)
/* 立即预热 InnerAudio,避免首拍冷启动延迟 */
fg.preload()
customExpanded.value = true
} else {
/* 收起:回到 preset,停掉 fg */
fg.stop()
mode.value = 'preset'
customExpanded.value = false
uni.setKeepScreenOn({ keepScreenOn: false })
}
}
const onBpmDelta = (delta: number) => {
fg.setBpm(fg.bpm.value + delta)
}
const onAccentSelect = (n: number) => {
fg.setAccentEvery(n)
}
/* ============================================================
* 视频实例与 BPM 同步
* ============================================================ */
let videoCtx: UniApp.VideoContext | null = null
const playbackRate = computed(() => {
const rate = currentBpm.value / VIDEO_BASE_BPM
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
})
onMounted(() => {
videoCtx = uni.createVideoContext('runner-video')
setTimeout(() => videoCtx?.pause(), 50)
})
const onVideoError = (e: any) => {
console.error('[runner-video] error:', e?.detail || e)
}
const onVideoLoaded = (e: any) => {
console.log('[runner-video] metadata loaded:', e?.detail)
try {
videoCtx?.playbackRate?.(playbackRate.value)
} catch (_) {}
}
watch(isPlaying, (playing) => {
if (playing) {
videoCtx?.play()
} else {
videoCtx?.pause()
}
})
watch(playbackRate, (rate) => {
try {
videoCtx?.playbackRate?.(rate)
} catch (_) {}
})
const rootStyle = computed(() => ({
'--beat-duration': `${intervalMs.value}ms`,
}))
onShow(() => {
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
if (mode.value === 'custom') {
fg.preload()
}
})
/**
* onHide:差异化处理
* - preset 模式: 不停,BgAudio 接管后台播放
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
*/
onHide(() => {
if (mode.value === 'custom') {
fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
}
})
onUnmounted(() => {
/* 页面销毁时全部清理 */
if (mode.value === 'preset' && bg.isPlaying.value) bg.stop()
if (mode.value === 'custom') fg.stop()
uni.setKeepScreenOn({ keepScreenOn: false })
})
</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;
}
/* ============================================================
* 节拍器主舞台
* ============================================================ */
.stage {
position: relative;
width: 480rpx;
height: 480rpx;
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: center;
&:active .core {
transform: scale(0.97);
}
}
.ring {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 3rpx solid rgba(16, 185, 129, 0.12);
pointer-events: none;
&.playing {
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
}
&.r2.playing {
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
}
}
@keyframes ring-pulse {
0% {
transform: scale(0.94);
opacity: 0.85;
border-color: rgba(16, 185, 129, 0.4);
}
100% {
transform: scale(1.18);
opacity: 0;
border-color: rgba(16, 185, 129, 0);
}
}
.core {
position: relative;
width: 380rpx;
height: 380rpx;
border-radius: 50%;
background: linear-gradient(140deg, #34d399 0%, $brand-deep 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow:
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
0 16rpx 40rpx rgba(16, 185, 129, 0.32);
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
&.playing {
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
}
.bpm-num {
font-size: 168rpx;
font-weight: 800;
color: #fff;
line-height: 1;
letter-spacing: -2rpx;
font-variant-numeric: tabular-nums;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.16);
}
.bpm-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 6rpx;
margin-top: 10rpx;
font-weight: 600;
}
.ctrl {
margin-top: 22rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
@keyframes core-beat {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.04);
}
}
/* 播放/暂停 icon */
.icon-play {
width: 0;
height: 0;
border-left: 52rpx solid #fff;
border-top: 32rpx solid transparent;
border-bottom: 32rpx solid transparent;
margin-left: 12rpx;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
}
.icon-pause {
display: flex;
gap: 16rpx;
height: 60rpx;
.bar {
width: 16rpx;
height: 100%;
background: #fff;
border-radius: 4rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
}
}
/* ============================================================
* 跑步小人
* ============================================================ */
.walker {
width: 100%;
height: 240rpx;
display: flex;
justify-content: center;
align-items: center;
}
.walker-box {
position: relative;
width: 240rpx;
height: 240rpx;
background: #e8ecf2;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06);
}
.runner-video {
width: 100%;
height: 100%;
background: #e8ecf2;
}
.pip-mask {
position: absolute;
top: 0;
right: 0;
width: 80rpx;
height: 56rpx;
background-color: #dde2eb;
border-bottom-left-radius: 18rpx;
border-top-right-radius: 24rpx;
}
/* ============================================================
* 三档位推荐
* ============================================================ */
.presets {
width: 100%;
display: flex;
gap: 14rpx;
}
.preset {
flex: 1;
background: $card-bg;
border: 2rpx solid $card-border;
border-radius: $radius-card;
padding: 18rpx 8rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
box-shadow: $shadow-sm;
transition: all 0.18s;
.preset-icon {
font-size: 40rpx;
line-height: 1;
margin-bottom: 4rpx;
}
.preset-name {
font-size: 28rpx;
font-weight: 700;
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;
}
}
}
/* ============================================================
* 自定义节奏面板(仅 customExpanded 时显示)
* ============================================================ */
.custom-panel {
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;
}
.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>
+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

+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 })
}
+9
View File
@@ -179,3 +179,12 @@ export function commissionSettlementConfirmFinalize(params: Record<string, any>)
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
}
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
start_date?: string
end_date?: string
}) {
return request.get({ url: '/stats.assistantPerformance/overview', params })
}
+27 -1
View File
@@ -319,6 +319,16 @@ export function prescriptionEdit(params: any) {
return request.post({ url: '/tcm.prescription/edit', params })
}
/** 仅修正处方笺患者姓名、手机号与性别(不改审核状态),写入业务订单日志表 */
export function prescriptionPatchPatient(params: {
id: number
patient_name: string
phone: string
gender: number
}) {
return request.post({ url: '/tcm.prescription/patchPatient', params })
}
// 删除处方
export function prescriptionDelete(params: { id: number }) {
return request.post({ url: '/tcm.prescription/delete', params })
@@ -452,15 +462,26 @@ export function prescriptionOrderAddPayOrder(params: {
order_type: number
pay_amount: number
pay_remark?: string
completion_request?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
}
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) {
export function prescriptionOrderLinkPayOrder(params: {
id: number
pay_order_id?: number
pay_order_ids?: number[]
completion_request?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
}
/** 已发货/已签收:仅提交完单申请(不新增/关联支付单) */
export function prescriptionOrderRequestCompletion(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
}
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
@@ -490,6 +511,11 @@ export function prescriptionOrderUpdateAmount(params: { id: number; amount: numb
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
}
/** 设置发货类型:gancao 甘草药房 / direct 洛阳药房 */
export function prescriptionOrderSetShipMode(params: { id: number; ship_mode: 'gancao' | 'direct' }) {
return request.post({ url: '/tcm.prescriptionOrder/setShipMode', params })
}
// ========== 处方库 ==========
// 处方库列表
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>
+584 -97
View File
@@ -216,6 +216,15 @@
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
查看
</el-button>
<el-button
v-if="Number(row.void_status) !== 1"
type="primary"
link
v-perms="['tcm.prescription/patchPatient']"
@click="openPatchPatientDialog(row)"
>
改姓名手机
</el-button>
<el-button
v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1"
type="success"
@@ -398,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">
@@ -640,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"
@@ -769,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 下拉选择 -->
@@ -872,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
@@ -980,6 +1131,48 @@
</template>
</el-drawer>
<!-- 修正处方笺姓名 / 手机号(zyt_tcm_prescription,写入订单日志) -->
<el-dialog
v-model="patchPatientVisible"
title="修正姓名、性别与手机号"
width="440px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetPatchPatientForm"
>
<el-form
ref="patchPatientFormRef"
:model="patchPatientForm"
:rules="patchPatientRules"
label-width="88px"
>
<el-form-item label="处方编号">
<span class="text-gray-700">#{{ patchPatientForm.id || '—' }}</span>
</el-form-item>
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="patchPatientForm.patient_name" maxlength="50" show-word-limit placeholder="处方笺展示姓名" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="patchPatientForm.gender">
<el-radio :label="1">男</el-radio>
<el-radio :label="0">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="patchPatientForm.phone" maxlength="20" placeholder="处方笺展示手机号" />
</el-form-item>
</el-form>
<p class="text-xs text-gray-500 -mt-2 mb-2">
仅更新处方表 patient_name、gender、phone,不改变审核状态;记录写入业务订单操作日志(有关联订单时可在此订单日志中查看)。
</p>
<template #footer>
<el-button @click="patchPatientVisible = false">取消</el-button>
<el-button type="primary" :loading="patchPatientSubmitLoading" @click="submitPatchPatient">
保存
</el-button>
</template>
</el-dialog>
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
<el-dialog
v-model="createOrderVisible"
@@ -1314,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"
@@ -1333,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
@@ -1343,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 }}味
@@ -1356,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 }">
@@ -1444,6 +1670,7 @@ import {
prescriptionLists,
prescriptionAdd,
prescriptionEdit,
prescriptionPatchPatient,
prescriptionDelete,
prescriptionAudit,
prescriptionDetail,
@@ -1470,6 +1697,119 @@ 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
}
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
}
}
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]
@@ -1531,6 +1871,66 @@ const auditTargetId = ref(0)
const auditRemark = ref('')
const auditLoading = ref(false)
/** 修正处方笺显示用姓名 / 手机号 */
const patchPatientVisible = ref(false)
const patchPatientSubmitLoading = ref(false)
const patchPatientFormRef = ref<FormInstance>()
const patchPatientForm = reactive({
id: 0,
patient_name: '',
gender: 1 as 0 | 1,
phone: ''
})
const patchPatientRules: FormRules = {
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
function openPatchPatientDialog(row: Record<string, unknown>) {
patchPatientForm.id = Number(row.id) || 0
patchPatientForm.patient_name = String(row.patient_name ?? '').trim()
const g = Number(row.gender)
patchPatientForm.gender = g === 0 || g === 1 ? (g as 0 | 1) : 1
patchPatientForm.phone = String(row.phone ?? '').trim()
patchPatientVisible.value = true
}
function resetPatchPatientForm() {
patchPatientForm.id = 0
patchPatientForm.patient_name = ''
patchPatientForm.gender = 1
patchPatientForm.phone = ''
patchPatientFormRef.value?.clearValidate()
}
async function submitPatchPatient() {
const form = patchPatientFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
patchPatientSubmitLoading.value = true
try {
await prescriptionPatchPatient({
id: patchPatientForm.id,
patient_name: patchPatientForm.patient_name.trim(),
phone: patchPatientForm.phone.trim(),
gender: patchPatientForm.gender
})
feedback.msgSuccess('已保存')
patchPatientVisible.value = false
getLists()
} catch (e: unknown) {
const msg = e && typeof e === 'object' && 'msg' in e ? String((e as { msg?: string }).msg) : ''
feedback.msgError(msg || '保存失败')
} finally {
patchPatientSubmitLoading.value = false
}
}
/** 从消费者处方创建订单 */
const createOrderVisible = ref(false)
/** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */
@@ -1811,7 +2211,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
})
@@ -2051,9 +2457,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('、')
@@ -2139,23 +2563,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(() => {
@@ -2350,7 +2777,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,
@@ -2376,7 +2803,8 @@ const editForm = reactive({
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
business_prescription_audit_rejected: 0,
business_prescription_audit_remark: '',
times_per_day: 2
times_per_day: 2,
aux_usage: defaultAuxUsage('浓缩水丸')
})
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
@@ -2467,6 +2895,7 @@ watch(() => editForm.prescription_type, (newType) => {
editForm.need_decoction = false
editForm.bags_per_dose = 1
}
Object.assign(editForm.aux_usage, defaultAuxUsage(newType))
})
// 表单验证规则
@@ -2695,15 +3124,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[] {
@@ -2926,6 +3355,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)
@@ -2970,8 +3401,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
@@ -3006,18 +3437,33 @@ 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)
}
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()
}
@@ -3149,7 +3595,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)
@@ -3157,7 +3603,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) {
@@ -3169,7 +3615,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)
}
@@ -3194,11 +3641,12 @@ async function handlePasteRecipeImport() {
}
}
// 添加药材
const addHerb = () => {
// 添加药材(主方 / 辅方)
const addHerb = (formulaType: FormulaType = '主方') => {
editForm.herbs.push({
name: '',
dosage: 0
dosage: 0,
formula_type: formulaType
})
}
@@ -3248,6 +3696,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()
}
@@ -3346,7 +3796,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
@@ -3371,6 +3821,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 确保所有数据都已经渲染完成
@@ -3405,12 +3859,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
}
}
@@ -3834,6 +4289,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
File diff suppressed because it is too large Load Diff
@@ -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"
@@ -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()
}
@@ -3047,14 +3119,48 @@ function formatTime(v: unknown) {
return String(v)
}
/** 与 server/config/project.php order_edit_all_roles 一致 */
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
function canBypassCreatorDualAuditEditLock(): boolean {
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 ids.some((id) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
}
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
const uid = Number(userStore.userInfo?.id)
return uid > 0 && Number(row.creator_id) === uid
}
function isDualAuditPassed(row: {
prescription_audit_status?: number
payment_slip_audit_status?: number
}) {
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
}
function canEditRow(row: {
creator_id?: number
fulfillment_status?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
const fs = Number(row.fulfillment_status)
if (fs === 3 || fs === 4) return false
if (
isCurrentUserOrderCreator(row) &&
isDualAuditPassed(row) &&
!canBypassCreatorDualAuditEditLock()
) {
return false
}
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0
@@ -3406,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)
@@ -3500,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
}
}
}
@@ -3643,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 = []
@@ -3659,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)
@@ -3889,7 +4038,25 @@ async function goEditOrderNextStep() {
}
}
async function openEdit(row: { id: number }) {
async function openEdit(row: {
id: number
creator_id?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
fulfillment_status?: number
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
if (!canEditRow(row)) {
if (
isCurrentUserOrderCreator(row) &&
isDualAuditPassed(row) &&
!canBypassCreatorDualAuditEditLock()
) {
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
}
return
}
editOrderStep.value = 0
editOrderPrescription.value = null
editVisible.value = true
+238 -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"
>
@@ -189,7 +189,7 @@
<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 +316,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 +445,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 +632,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 +698,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 +852,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 +895,8 @@
<el-empty
v-else-if="
!loading &&
(!canViewDoctorDailyStats || !doctorDailyLoading) &&
!leaderboardsLoading
(!canViewDoctorTab || !doctorDailyLoading) &&
(!canViewLeaderboardTab || !leaderboardsLoading)
"
class="yeji-charts-empty"
description="当前筛选下暂无图表数据,请调整条件后查询"
@@ -1159,9 +1156,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 +1325,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 +1344,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 +1640,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 +1679,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 +1776,7 @@ const doctorRxStackHasData = computed(() =>
)
const doctorChartsHasData = computed(() => {
if (!canViewDoctorDailyStats.value) {
if (!canViewDoctorTab.value) {
return false
}
return (
@@ -1731,12 +1792,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
@@ -2073,6 +2136,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 +2280,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 +2531,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 +2568,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 +2631,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 +2750,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 +3139,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 +3250,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 +3738,7 @@ function formatLocalYmd(d: Date): string {
}
onMounted(async () => {
yejiDisplayTab.value = pickInitialYejiTab()
await Promise.all([loadDeptOptions(), loadChannelOptions()])
loadData()
loadTargetProgress()
@@ -4832,6 +5010,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;
+39 -12
View File
@@ -22,16 +22,25 @@
<el-select
v-model="formData.media_channel_code"
placeholder="请选择自媒体渠道"
class="w-full"
class="account-cost-channel-select w-full"
filterable
:disabled="mode === 'edit'"
>
<el-option
v-for="item in mediaChannelOptions"
:key="item.code"
:label="item.name"
:value="item.code"
/>
<el-option-group
v-for="g in mediaChannelGroups"
:key="g.group_name"
:label="g.group_name"
>
<el-option
v-for="ch in g.channels"
:key="ch.channel_code"
:label="ch.channel_name"
:value="ch.channel_code"
>
<div class="channel-opt-row">
<span class="opt-name">{{ ch.channel_name }}</span>
</div>
</el-option>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="部门" prop="dept_id">
@@ -78,9 +87,9 @@ import type { FormInstance } from 'element-plus'
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
import Popup from '@/components/popup/index.vue'
interface MediaChannelOption {
code: string
name: string
interface MediaChannelGroup {
group_name: string
channels: { channel_code: string; channel_name: string }[]
}
interface DeptOption {
@@ -90,7 +99,7 @@ interface DeptOption {
}
const props = defineProps<{
mediaChannelOptions: MediaChannelOption[]
mediaChannelGroups: MediaChannelGroup[]
deptOptions: DeptOption[]
defaultMediaChannelCode: string
}>()
@@ -197,3 +206,21 @@ defineExpose({
getDetail,
})
</script>
<style scoped lang="scss">
.account-cost-channel-select {
:deep(.channel-opt-row) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
padding-right: 4px;
}
:deep(.opt-name) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
+83 -10
View File
@@ -27,14 +27,25 @@
placeholder="筛选全部渠道"
clearable
filterable
class="w-[220px]"
class="account-cost-channel-select w-[220px]"
>
<el-option
v-for="item in mediaChannelOptions"
:key="item.code"
:label="item.name"
:value="item.code"
/>
<el-option label="全部" value="" />
<el-option-group
v-for="g in mediaChannelGroups"
:key="g.group_name"
:label="g.group_name"
>
<el-option
v-for="ch in g.channels"
:key="ch.channel_code"
:label="ch.channel_name"
:value="ch.channel_code"
>
<div class="channel-opt-row">
<span class="opt-name">{{ ch.channel_name }}</span>
</div>
</el-option>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="部门">
@@ -78,7 +89,15 @@
<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_channel_name" min-width="120" />
<el-table-column label="自媒体渠道" min-width="160">
<template #default="{ row }">
<span v-if="getChannelGroupLabel(row.media_channel_code)">
<el-tag size="small" class="mr-1">{{ getChannelGroupLabel(row.media_channel_code) }}</el-tag>
{{ row.media_channel_name }}
</span>
<span v-else>{{ row.media_channel_name }}</span>
</template>
</el-table-column>
<el-table-column label="部门" prop="dept_name" min-width="140" />
<el-table-column label="账户消耗" min-width="120">
<template #default="{ row }">¥{{ row.amount }}</template>
@@ -117,7 +136,7 @@
<edit-popup
v-if="showEdit"
ref="editRef"
:media-channel-options="mediaChannelOptions"
:media-channel-groups="mediaChannelGroups"
:dept-options="deptOptions"
:default-media-channel-code="defaultMediaChannelCode"
@success="getLists"
@@ -133,6 +152,11 @@ import feedback from '@/utils/feedback'
import EditPopup from './edit.vue'
interface MediaChannelGroup {
group_name: string
channels: { channel_code: string; channel_name: string }[]
}
interface MediaChannelOption {
code: string
name: string
@@ -160,7 +184,7 @@ const { pager, getLists, resetPage } = usePaging({
params: queryParams,
})
const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
const options = pager.extend.media_channel_options
if (!Array.isArray(options)) return []
@@ -170,6 +194,26 @@ const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
}))
})
const mediaChannelGroups = computed<MediaChannelGroup[]>(() => {
const groups = pager.extend.media_channel_groups
if (Array.isArray(groups) && groups.length > 0) {
return groups as MediaChannelGroup[]
}
const flat = mediaChannelOptionsFlat.value
if (!flat.length) {
return []
}
return [
{
group_name: '渠道',
channels: flat.map(item => ({
channel_code: item.code,
channel_name: item.name,
})),
},
]
})
const deptOptions = computed<DeptOption[]>(() => {
const options = pager.extend.dept_options
return Array.isArray(options) ? options : []
@@ -177,6 +221,17 @@ const deptOptions = computed<DeptOption[]>(() => {
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
const getChannelGroupLabel = (channelCode: string): string => {
if (!channelCode) return ''
for (const group of mediaChannelGroups.value) {
const channel = group.channels.find(ch => ch.channel_code === channelCode)
if (channel) {
return group.group_name
}
}
return ''
}
const treeProps = {
value: 'id',
label: 'name',
@@ -213,3 +268,21 @@ const handleReset = () => {
getLists()
</script>
<style scoped lang="scss">
.account-cost-channel-select {
:deep(.channel-opt-row) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
padding-right: 4px;
}
:deep(.opt-name) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
@@ -0,0 +1,273 @@
<template>
<div class="assistant-performance-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="stats-filter-form">
<el-form-item label="时间范围">
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">最近30天</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="queryParams.time_type === 'custom'">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
@change="handleCustomDateChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="fetchData">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="stats-kpi-grid">
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card" :class="card.cardClass">
<div class="stats-kpi-label">{{ card.label }}</div>
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
{{ formatValue(card.key, card.type) }}
</div>
</div>
</div>
<el-card class="!border-none mt-4" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">业绩趋势</span>
<span class="card-hint">{{ dateRangeText }} · 履约完成业绩</span>
</div>
</template>
<v-charts
v-if="chartHasData"
class="stats-chart"
:option="chartOption"
autoresize
/>
<el-empty v-else description="暂无数据" />
</el-card>
</div>
</template>
<script setup lang="ts" name="assistantPerformancePage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import vCharts from 'vue-echarts'
import { assistantPerformanceOverview } from '@/api/stats'
const loading = ref(false)
const dateRange = ref<string[]>([])
const queryParams = reactive({
time_type: 'month',
start_date: '',
end_date: ''
})
const overview = reactive<Record<string, any>>({
date_range: [],
summary: {
total_amount: 0,
total_count: 0
},
chart: {
dates: [],
amounts: [],
counts: []
}
})
const summaryCards = [
{ key: 'total_amount', label: '业绩', type: 'money', cardClass: '' },
{ key: 'total_count', label: '有效订单数', type: 'count', cardClass: '' }
]
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
})
const chartHasData = computed(() => {
return overview.chart.dates.length > 0 && overview.chart.amounts.some((v: number) => v > 0)
})
const chartOption = computed(() => ({
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const p = params[0]
return `${p.axisValue}<br/>${p.marker}${p.seriesName}: ¥${Number(p.value).toFixed(2)}`
}
},
grid: { left: 60, right: 24, top: 36, bottom: 36 },
xAxis: {
type: 'category',
data: overview.chart.dates,
axisLabel: {
interval: overview.chart.dates.length > 15 ? 'auto' : 0,
rotate: overview.chart.dates.length > 10 ? 30 : 0
}
},
yAxis: {
type: 'value',
name: '金额(元)',
axisLabel: {
formatter: (val: number) => val >= 10000 ? (val / 10000).toFixed(1) + '万' : String(val)
}
},
series: [
{
name: '业绩',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 6,
lineStyle: { width: 3, color: '#4a78ff' },
itemStyle: { color: '#4a78ff' },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(74, 120, 255, 0.25)' },
{ offset: 1, color: 'rgba(74, 120, 255, 0.02)' }
]
}
},
data: overview.chart.amounts
}
]
}))
const formatValue = (key: string, type: string) => {
const value = overview.summary?.[key] ?? 0
if (type === 'money') return `¥${Number(value).toFixed(2)}`
return String(value)
}
const fetchData = async () => {
loading.value = true
try {
const params: Record<string, any> = { time_type: queryParams.time_type }
if (queryParams.time_type === 'custom') {
params.start_date = dateRange.value[0] || ''
params.end_date = dateRange.value[1] || ''
}
const res = await assistantPerformanceOverview(params)
Object.assign(overview, res || {})
} catch (error: any) {
console.error('获取业绩数据失败:', error)
ElMessage.error(error?.msg || '获取业绩数据失败')
} finally {
loading.value = false
}
}
const handleTimeTypeChange = () => {
if (queryParams.time_type !== 'custom') {
dateRange.value = []
fetchData()
}
}
const handleCustomDateChange = () => {
if (dateRange.value?.length === 2) {
fetchData()
}
}
const handleReset = () => {
queryParams.time_type = 'month'
dateRange.value = []
fetchData()
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.assistant-performance-page {
padding: 20px;
}
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stats-kpi-card {
background: linear-gradient(145deg, #ffffff, #f5f8ff);
border: 1px solid #ebf1ff;
border-radius: 14px;
padding: 20px;
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
}
.stats-kpi-card.is-cancelled {
background: linear-gradient(145deg, #ffffff, #fff5f5);
border-color: #ffe0e0;
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.06);
}
.stats-kpi-label {
color: #6b7280;
font-size: 13px;
}
.stats-kpi-value {
margin-top: 12px;
font-size: 28px;
line-height: 1.1;
font-weight: 700;
color: #1f2937;
}
.stats-kpi-value.is-money {
font-size: 24px;
}
.stats-kpi-card.is-cancelled .stats-kpi-value {
color: #ef4444;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.card-hint {
color: #909399;
font-size: 12px;
}
.stats-chart {
height: 360px;
}
</style>
@@ -0,0 +1,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>
+499
View File
@@ -0,0 +1,499 @@
<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: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
{ key: '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: '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>
@@ -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;
}
@@ -131,7 +131,7 @@
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
<el-descriptions-item label="剂数 / 用法" :span="2">
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '' }} {{ detailData.dose_unit || '' }} ·
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '' }}
</el-descriptions-item>
@@ -274,6 +274,7 @@
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
@@ -332,7 +333,7 @@
</template>
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { computed, watch, ref, onMounted } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import {
prescriptionOrderLists,
@@ -341,6 +342,7 @@ import {
prescriptionOrderLogisticsTrace,
prescriptionOrderPaidPayOrders
} from '@/api/tcm'
import { getDictData } from '@/api/app'
import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback'
import useUserStore from '@/stores/modules/user'
@@ -377,6 +379,22 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit'
}
/** 诊间医助角色(与 DiagnosisLists 等一致) */
const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
/**
* 是否展示「处方审核」相关字段
*/
const showPrescriptionAuditFilter = computed(() => {
const u = userStore.userInfo
if (!u || Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
})
// ─── 详情抽屉 ───
const detailVisible = ref(false)
const detailLoading = ref(false)
@@ -563,6 +581,39 @@ function feeTypeText(t: number | undefined) {
return m[Number(t)] ?? '—'
}
// 服务套餐字典选项(与 order_list.vue 数据源一致:dict 类型 server_order
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
async function loadServicePackageOptions() {
try {
const data: any = await getDictData({ type: 'server_order' })
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
servicePackageOptions.value = list.filter((item) => item.status !== 0)
} catch {
servicePackageOptions.value = []
}
}
function formatServicePackage(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
let packages: string[] = []
if (Array.isArray(value)) {
packages = value.map((v) => String(v)).filter((v) => v !== '')
} else if (typeof value === 'string') {
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
}
if (packages.length === 0) return '—'
const names = packages.map((val) => {
const opt = servicePackageOptions.value.find((o) => o.value === val)
return opt ? opt.name : val
})
return names.join('、')
}
onMounted(() => {
loadServicePackageOptions()
})
function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
+1 -1
View File
@@ -257,7 +257,7 @@
<template #default="{ row }">
<el-tooltip
v-if="row.last_blood_record_at"
:content="`最近一次血糖打卡:${row.last_blood_record_at}`"
:content="`最近一次健康打卡:${row.last_blood_record_at}`"
placement="top"
>
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
+2 -2
View File
@@ -17,14 +17,14 @@
</span>
<el-tooltip
v-if="lastBloodAt"
:content="`最近一次血糖打卡:${lastBloodAt}`"
:content="`最近一次健康打卡:${lastBloodAt}`"
placement="bottom"
>
<span :class="['unserved-badge', unservedDaysClass(unservedDays)]">
未服务 {{ unservedDays ?? '—' }}
</span>
</el-tooltip>
<span v-else class="unserved-badge unserved-muted">从未打卡血糖</span>
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
</div>
</div>
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
@@ -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('删除成功');
}
}
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File

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