Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:17:16 +08:00
Your Name 49c8c95966 更新 2026-05-21 14:41:43 +08:00
Your Name c7c264497e 修复bug 2026-05-21 14:38:31 +08:00
long 436f69c272 fix: support both exclusive and public resources for logged in users, include flags in quick returns 2026-05-21 14:36:02 +08:00
long dc72a8ccaf fix: getResourceList never returns -1, uses flags for disabled/expired tokens to keep guest mode working 2026-05-21 10:25:15 +08:00
long 499c969233 fix: distinguish disabled users from invalid tokens - disabled users see public resources as guest, only truly invalid tokens trigger re-login 2026-05-21 10:13:33 +08:00
long 3b44300216 fix: return code -1 when disabled user has token in getResourceList, triggers frontend logout 2026-05-21 10:08:15 +08:00
long 8bb7b5d30a feat: add inline switch toggle for account status in user list 2026-05-21 10:01:58 +08:00
long 4c3becf33e feat: use project standard search bar pattern for asset pages, add title/phone/date filters 2026-05-21 09:54:59 +08:00
long a42fc6453a feat: add resource edit functionality and time range filter in admin panel 2026-05-21 09:49:30 +08:00
long dad06304f1 feat: make user_ids optional in admin resource form, unlinked resources become public 2026-05-21 09:47:44 +08:00
long 03b80310f6 feat: add public resource support, usage filtering, and recordDownload API 2026-05-20 18:24:04 +08:00
long 8d015fa962 fix: dual-write token (DB+cache) and dual-read with fallback, works with or without SQL migration 2026-05-20 17:54:21 +08:00
long f639ef2fbb feat: add time dimension filter (today/3d/7d/30d) to resource list 2026-05-20 17:38:28 +08:00
long ea8e6d7c1b fix: switch asset token from file cache to database for reliability + always redirect on token invalid 2026-05-20 17:22:12 +08:00
Your Name ba1c067dce Merge branch 'master' into bug-5-20 2026-05-20 17:15:51 +08:00
Your Name 5e96fda88f 更新 2026-05-20 17:14:27 +08:00
long 076cd0fcf9 fix: add changePassword to notNeedLogin for asset token auth 2026-05-20 17:13:26 +08:00
long 44e97861d2 fix: use OSS direct upload for video and audio in resource admin 2026-05-20 16:59:19 +08:00
Your Name 93e6d02ce4 更新 2026-05-20 16:53:19 +08:00
long 60cb2b80cd feat: add dedicated change password api and fix token expire code 2026-05-20 16:53:08 +08:00
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
Your Name 7ba9dabdb4 更新 2026-05-14 16:26:27 +08:00
Your Name 611f3dcd5c Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 16:23:52 +08:00
Your Name d3388b160e 更新 2026-05-14 16:23:29 +08:00
long f83d4c06cb Merge branch 'long-20260514' 2026-05-14 16:22:38 +08:00
long 4941ea3e21 Update DailyMatrix.vue
更新血糖折线图显示
2026-05-14 15:54:30 +08:00
Your Name e35696e153 更新 2026-05-14 15:34:28 +08:00
gr 800c6af7f1 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 11:31:40 +08:00
Your Name e1df584ed5 更新 2026-05-14 11:25:48 +08:00
gr 876ef0f8a0 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 11:02:29 +08:00
Your Name 928f75e016 更新 2026-05-14 11:00:56 +08:00
Your Name 37fa160c24 更新 2026-05-13 17:23:29 +08:00
Your Name 0eaacbb4ce 更新 2026-05-13 16:54:54 +08:00
Your Name 1e8b5c4646 修复bug 2026-05-13 09:49:47 +08:00
gr fdcfa30810 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 18:15:10 +08:00
Your Name fabaa84373 新增 2026-05-12 18:14:20 +08:00
Your Name da017bdf20 更新 2026-05-12 18:10:40 +08:00
Your Name 5d94ffab3e 更新 2026-05-12 17:58:54 +08:00
Your Name 796ca12fb8 更新 2026-05-12 17:26:11 +08:00
gr 4caa98af3a Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 11:27:37 +08:00
Your Name c644f2554f 更新 2026-05-12 11:26:51 +08:00
gr bb87e4fb4c Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 10:59:56 +08:00
Your Name e83dac756d 新增 2026-05-12 10:59:03 +08:00
Your Name 0c1709d589 Merge branch 'master' into yeji-5-11 2026-05-12 10:43:04 +08:00
Your Name a3892001d3 更新 2026-05-12 10:42:14 +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
Your Name 7add364e20 更新 2026-05-11 17:48:47 +08:00
Your Name 0ebea74fde 更新 2026-05-11 17:46:29 +08:00
Your Name 1156787327 更新 2026-05-11 16:34:44 +08:00
Your Name 15cf6324f6 更新 2026-05-11 16:10:06 +08:00
long 1338b8735c Merge branch '20260511-long' 2026-05-11 15:06:07 +08:00
long 82f229adf2 Update 2026_05_11_diagnosis_assign_read.sql 2026-05-11 15:06:01 +08:00
long 048e43a662 Merge branch '20260511-long' 2026-05-11 15:04:33 +08:00
long d8d4da197e 新指派医助提示 2026-05-11 15:04:24 +08:00
gr a0325a8fb1 更新 2026-05-11 12:35:46 +08:00
Your Name 091db50841 更新 2026-05-11 12:32:25 +08:00
long 1774a87dd7 update 2026-05-11 11:41:42 +08:00
Your Name 62d15efbaf 更新 2026-05-11 11:36:26 +08:00
long c468c57cda 关联支付单豁免 2026-05-11 11:25:03 +08:00
Your Name bacdd6386f 新增 2026-05-09 18:36:46 +08:00
long ea290c4c7b Merge branch 'long-0509' 2026-05-09 18:32:23 +08:00
long 31b4afdcb7 Update PrescriptionLogic.php 2026-05-09 18:32:16 +08:00
long 379d2ef404 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-09 18:07:38 +08:00
long 5cdb7ebb9e Merge branch 'long-0509' 2026-05-09 18:07:37 +08:00
long 496a2b870b 业务订单优化 2026-05-09 18:07:30 +08:00
Your Name 471bdf12b7 更新 2026-05-09 17:45:04 +08:00
long 8fe4683e64 Revert "1"
This reverts commit 0a37335f3c.
2026-05-09 17:35:33 +08:00
long c34409edde Merge branch 'long-0509' 2026-05-09 17:14:13 +08:00
long 0a37335f3c 1 2026-05-09 17:14:03 +08:00
Your Name 985916dea7 修复bug 2026-05-09 17:04:50 +08:00
Your Name 3330613254 更新 2026-05-09 15:57:12 +08:00
Your Name 3f12829204 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-09 11:35:21 +08:00
Your Name f28500e449 更新 2026-05-09 11:35:09 +08:00
long fd9053a290 患者列表优化 2026-05-09 11:30:04 +08:00
Your Name dc1c5d5b17 更新 2026-05-08 18:32:39 +08:00
Your Name 1297d9f510 更新 2026-05-08 17:26:51 +08:00
6132 changed files with 39207 additions and 2028 deletions
+5
View File
@@ -29,3 +29,8 @@ bin-release/
/.claude
/.claude
/.claude
/.kbctx
/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
+54
View File
@@ -105,6 +105,60 @@
}
}
]
},
{
"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": "#204e2b",
"navigationBarTextStyle": "white",
"backgroundColor": "#faf9f5",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/more",
"style": {
"navigationBarTitleText": "日常护理",
"navigationBarBackgroundColor": "#204e2b",
"navigationBarTextStyle": "white",
"backgroundColor": "#faf9f5",
"enablePullDownRefresh": true
}
}
]
}
],
"tabBar": {
+70
View File
@@ -53,6 +53,18 @@
<text class="time-value">{{ card.create_time || '-' }}</text>
</view>
</view>
<!-- 快捷入口 -->
<view class="card-actions">
<view class="card-action-btn primary" @click.stop="viewDailyRecord(card)">
<text class="card-action-icon">📈</text>
<text class="card-action-text">日常记录</text>
</view>
<view class="card-action-btn" @click.stop="viewCardDetail(card)">
<text class="card-action-icon">📝</text>
<text class="card-action-text">查看 / 编辑</text>
</view>
</view>
</view>
<!-- 空状态 -->
@@ -138,6 +150,16 @@ const viewCardDetail = (card) => {
})
}
// 查看日常记录(血糖血压 / 饮食 / 运动 / 跟踪备注 + 波浪图)
const viewDailyRecord = (card) => {
const url = `/tongji/pages/index?diagnosis_id=${card.id}` +
`&patient_id=${card.patient_id || ''}` +
`&patient_name=${encodeURIComponent(card.patient_name || '')}` +
`&age=${card.age || ''}` +
`&gender=${card.gender || ''}`
uni.navigateTo({ url })
}
// 新建就诊卡(patient_id 创建后自动生成,仅需登录)
const createCard = () => {
const token = uni.getStorageSync('token')
@@ -310,6 +332,54 @@ const formatDate = (timestamp) => {
border-top: 4rpx solid #e8eaed;
}
.card-actions {
display: flex;
gap: 20rpx;
margin-top: 28rpx;
padding-top: 28rpx;
border-top: 4rpx solid #e8eaed;
}
.card-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 24rpx 12rpx;
background: #f6faff;
border-radius: 20rpx;
border: 2rpx solid #d6e6fb;
min-height: 80rpx;
transition: all 0.2s;
&:active {
transform: scale(0.98);
opacity: 0.85;
}
&.primary {
background: linear-gradient(135deg, #1890ff, #0ea5a4);
border-color: transparent;
.card-action-icon,
.card-action-text {
color: #ffffff;
}
}
}
.card-action-icon {
font-size: 36rpx;
color: #1890ff;
}
.card-action-text {
font-size: 30rpx;
color: #1890ff;
font-weight: 500;
}
.time-info {
display: flex;
align-items: center;
+14 -1
View File
@@ -778,6 +778,7 @@ const showDatePicker = ref(false)
const diagnosisId = ref(null)
const patientId = ref(null)
const isAddMode = ref(false) // 新建模式
const returnUrl = ref('') // 保存成功后跳回(如日常记录页)
// 表单数据
const formData = ref({
@@ -1023,6 +1024,13 @@ const loadCardDetail = async () => {
const options = currentPage.options || {}
diagnosisId.value = options.id
isAddMode.value = options.add === '1' || options.add === 1
if (options.returnUrl) {
try {
returnUrl.value = decodeURIComponent(String(options.returnUrl))
} catch (e) {
returnUrl.value = String(options.returnUrl)
}
}
const userData = uni.getStorageSync('userData')
patientId.value = userData?.diagnosis?.patient_id
@@ -1302,7 +1310,12 @@ const submitForm = async () => {
}
uni.showToast({ title: isAddMode.value ? '创建成功' : '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
const back = returnUrl.value
if (back && back.startsWith('/')) {
uni.redirectTo({ url: back })
} else {
uni.navigateBack()
}
}, 1500)
} else {
uni.showToast({ title: res.msg || '保存失败', icon: 'none' })
+5
View File
@@ -88,13 +88,18 @@
</div>
</div>
</div>
<!-- DEV 开发悬浮入口生产环境自动隐藏 -->
<dev-training-entry />
</div>
</template>
<script>
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
export default {
name: 'profileB',
components: { DevTrainingEntry },
data() {
return {
userInfo: {
+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)
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,142 @@
<template>
<view v-if="show" class="celebrate-root" @touchmove.stop.prevent>
<view
v-for="p in particles"
:key="p.id"
class="celebrate-particle"
:style="p.style"
/>
<view v-if="title" class="celebrate-card" :class="{ pop: cardPop }">
<view class="celebrate-card-glow" />
<TongjiIcon name="sparkles" size="lg" color="#204E2B" />
<text class="celebrate-card-title">{{ title }}</text>
<text v-if="subtitle" class="celebrate-card-sub">{{ subtitle }}</text>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
import TongjiIcon from './TongjiIcon.vue'
const props = defineProps({
show: { type: Boolean, default: false },
title: { type: String, default: '' },
subtitle: { type: String, default: '' }
})
const particles = ref([])
const cardPop = ref(false)
const COLORS = ['#204E2B', '#386641', '#AFE2B3', '#FBBF24', '#727970', '#DC2626']
function buildParticles() {
const list = []
for (let i = 0; i < 18; i++) {
const left = 8 + Math.random() * 84
const delay = Math.random() * 0.35
const hue = COLORS[i % COLORS.length]
const size = 10 + Math.floor(Math.random() * 14)
list.push({
id: `${Date.now()}_${i}`,
style: {
left: `${left}%`,
width: `${size}rpx`,
height: `${size}rpx`,
background: hue,
animationDelay: `${delay}s`
}
})
}
return list
}
watch(
() => props.show,
(v) => {
if (v) {
particles.value = buildParticles()
cardPop.value = false
setTimeout(() => {
cardPop.value = true
}, 30)
} else {
particles.value = []
cardPop.value = false
}
}
)
</script>
<style lang="scss" scoped>
.celebrate-root {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
overflow: hidden;
}
.celebrate-particle {
position: absolute;
top: -20rpx;
border-radius: 4rpx;
opacity: 0.9;
animation: celebrate-fall 1.6s ease-in forwards;
}
@keyframes celebrate-fall {
0% {
transform: translateY(0) rotate(0deg) scale(1);
opacity: 1;
}
100% {
transform: translateY(110vh) rotate(540deg) scale(0.4);
opacity: 0;
}
}
.celebrate-card {
position: absolute;
left: 50%;
top: 38%;
transform: translate(-50%, -50%) scale(0.82);
width: 78%;
max-width: 560rpx;
padding: 36rpx 32rpx 32rpx;
background: rgba(255, 255, 255, 0.96);
border-radius: 28rpx;
box-shadow: 0 20rpx 60rpx rgba(8, 145, 178, 0.22);
border: 2rpx solid rgba(8, 145, 178, 0.12);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
opacity: 0;
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
&.pop {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.celebrate-card-glow {
position: absolute;
inset: -20rpx;
border-radius: 36rpx;
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 70%);
pointer-events: none;
}
.celebrate-card-title {
position: relative;
z-index: 1;
margin-top: 16rpx;
font-size: 36rpx;
font-weight: 800;
color: #1b1c1a;
}
.celebrate-card-sub {
position: relative;
z-index: 1;
margin-top: 10rpx;
font-size: 26rpx;
color: #475569;
line-height: 1.45;
}
</style>
@@ -0,0 +1,199 @@
<template>
<view
class="sugar-tree-graphic"
:class="[`lv-${clampedLevel}`, `tier-${visualTier}`, `size-${size}`, { watering: watering, 'is-max': clampedLevel >= MAX_LEVEL }]"
>
<view class="stg-glow" />
<view v-if="clampedLevel >= 7" class="stg-aura" />
<image v-if="!treeUseFallback" class="stg-image" :src="treeImageSrc" mode="aspectFit" @error="treeUseFallback = true" />
<text v-else class="stg-emoji">{{ treeEmoji }}</text>
<view v-if="clampedLevel >= 7" class="stg-sparkle stg-sparkle-a" />
<view v-if="clampedLevel >= 8" class="stg-sparkle stg-sparkle-b" />
<view v-if="clampedLevel >= MAX_LEVEL" class="stg-sparkle stg-sparkle-c" />
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
import { TREE_MAX_LEVEL, TREE_LEVELS } from '../utils/treeLevels.js'
const MAX_LEVEL = TREE_MAX_LEVEL
// #ifdef MP-WEIXIN
const treeUseFallback = ref(true)
// #endif
// #ifndef MP-WEIXIN
const treeUseFallback = ref(false)
// #endif
const props = defineProps({
level: { type: Number, default: 0 },
size: { type: String, default: 'md' },
watering: { type: Boolean, default: false }
})
const clampedLevel = computed(() => Math.min(MAX_LEVEL, Math.max(0, Number(props.level) || 0)))
const visualTier = computed(() => {
const lv = clampedLevel.value
if (lv <= 0) return 0
if (lv <= 2) return 1
if (lv <= 4) return 2
if (lv <= 6) return 3
if (lv <= 8) return 4
return 5
})
const treeEmoji = computed(() => (TREE_LEVELS[clampedLevel.value] || TREE_LEVELS[0]).emoji)
function buildTreeSvg(level) {
const pot = `
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#204e2b" opacity="0.12"/>
<path d="M11 46h26c1.2 0 2 1 2 2.2v3.8c0 1-.8 1.8-1.8 1.8H10.8c-1 0-1.8-.8-1.8-1.8v-3.8c0-1.2.8-2.2 2-2.2z" fill="#E7E5E4"/>
<path d="M12.5 46h23c.8 0 1.5.7 1.5 1.5v2.2c0 .6-.5 1.1-1.1 1.1H12.1c-.6 0-1.1-.5-1.1-1.1v-2.2c0-.8.7-1.5 1.5-1.5z" fill="#D6D3D1"/>
<ellipse cx="24" cy="46.5" rx="9" ry="1.6" fill="#A8A29E" opacity="0.35"/>
`
const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#386641" opacity="0.18"/>`
if (level <= 0) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">
${pot}${soil}
<circle cx="24" cy="41.5" r="2.2" fill="#a8a29e" opacity="0.7"/>
<path d="M24 41.5v3.5" stroke="#78716c" stroke-width="1" stroke-linecap="round"/>
</svg>`
}
const trunkH = level >= 6 ? 16 : level >= 3 ? 14 : 10
const trunkY = 46 - trunkH
const trunk = `<rect x="22.2" y="${trunkY}" width="3.6" height="${trunkH}" rx="1.8" fill="#78716C"/>
<rect x="22.6" y="${trunkY + 1}" width="2.8" height="${trunkH - 1}" rx="1.4" fill="#A8A29E" opacity="0.35"/>`
const leaf = (cx, cy, r, fill, opacity = 1) =>
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" opacity="${opacity}"/>
<circle cx="${cx - r * 0.25}" cy="${cy - r * 0.2}" r="${r * 0.35}" fill="#eef6ef" opacity="0.55"/>`
const bloom =
level >= 7
? leaf(17, 18, 3, '#fda4af', 0.95) +
leaf(31, 17, 2.8, '#f9a8d4', 0.9) +
leaf(24, 13, 3.2, '#fb7185', 0.95) +
`<circle cx="24" cy="12" r="1.3" fill="#fef3c7"/>`
: ''
const crown =
level >= 9
? leaf(12, 22, 6, '#34d399', 0.9) +
leaf(36, 22, 6, '#34d399', 0.9) +
leaf(24, 10, 7, '#386641', 0.95) +
bloom
: bloom
let canopy = ''
if (level === 1) {
canopy = leaf(24, 38, 4, '#afe2b3') + `<path d="M24 38v-5" stroke="#386641" stroke-width="1.2" stroke-linecap="round"/>`
} else if (level === 2) {
canopy = leaf(24, 32, 5.5, '#34d399') + leaf(20, 34, 3.5, '#6ee7b7', 0.9)
} else if (level <= 4) {
canopy =
leaf(24, 28, 7, '#34d399') +
leaf(17, 30, 5, '#6ee7b7', 0.9) +
leaf(31, 30, 5, '#6ee7b7', 0.9)
} else if (level <= 6) {
canopy =
leaf(24, 24, 9, '#22c55e') +
leaf(14, 26, 7, '#4ade80', 0.92) +
leaf(34, 26, 7, '#4ade80', 0.92) +
leaf(24, 16, 6, '#386641', 0.88)
} else {
canopy =
leaf(24, 20, 10, '#204e2b') +
leaf(13, 24, 8, '#34d399', 0.95) +
leaf(35, 24, 8, '#34d399', 0.95) +
crown
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">${pot}${soil}${trunk}${canopy}</svg>`
}
const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value)))
</script>
<style lang="scss" scoped>
.sugar-tree-graphic {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.size-sm { width: 64rpx; height: 72rpx; }
.size-md { width: 80rpx; height: 88rpx; }
.size-lg { width: 112rpx; height: 124rpx; }
.stg-glow {
position: absolute;
inset: 6%;
border-radius: 50%;
background: radial-gradient(circle, rgba(32, 78, 43, 0.22) 0%, transparent 68%);
pointer-events: none;
}
.lv-0 .stg-glow { background: radial-gradient(circle, rgba(148, 163, 184, 0.25) 0%, transparent 70%); }
.tier-4 .stg-glow,
.tier-5 .stg-glow,
.is-max .stg-glow {
background: radial-gradient(circle, rgba(251, 191, 36, 0.3) 0%, rgba(32, 78, 43, 0.15) 55%, transparent 72%);
}
.stg-aura {
position: absolute;
inset: -8%;
border-radius: 50%;
border: 2rpx solid rgba(253, 224, 71, 0.35);
animation: stg-aura-pulse 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes stg-aura-pulse {
0%, 100% { transform: scale(0.92); opacity: 0.5; }
50% { transform: scale(1.05); opacity: 1; }
}
.stg-image {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
transition: transform 0.35s ease;
}
.sugar-tree-graphic.watering .stg-image,
.sugar-tree-graphic.watering .stg-emoji {
animation: stg-water-bounce 0.65s ease;
}
.stg-emoji {
position: relative;
z-index: 1;
line-height: 1;
font-size: 72rpx;
}
.size-sm .stg-emoji { font-size: 48rpx; }
.size-md .stg-emoji { font-size: 60rpx; }
.size-lg .stg-emoji { font-size: 88rpx; }
@keyframes stg-water-bounce {
0%, 100% { transform: scale(1); }
35% { transform: scale(1.1) translateY(-6rpx); }
60% { transform: scale(0.96) translateY(2rpx); }
}
.stg-sparkle {
position: absolute;
z-index: 2;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #fde68a;
box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8);
pointer-events: none;
animation: stg-sparkle-twinkle 1.8s ease-in-out infinite;
}
.stg-sparkle-a { top: 4%; right: 16%; }
.stg-sparkle-b { top: 12%; left: 10%; width: 6rpx; height: 6rpx; animation-delay: 0.4s; }
.stg-sparkle-c { top: 22%; right: 28%; width: 10rpx; height: 10rpx; animation-delay: 0.8s; }
@keyframes stg-sparkle-twinkle {
0%, 100% { opacity: 0.4; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
</style>
@@ -0,0 +1,152 @@
<template>
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
<image
v-if="!useFallback"
class="tj-icon"
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
:src="iconSrc"
mode="aspectFit"
@error="onImageError"
/>
<text
v-else
class="tj-icon-fallback"
:class="[`tj-icon-fallback--${size}`]"
:style="{ color }"
>{{ fallbackGlyph }}</text>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
const props = defineProps({
name: { type: String, required: true },
size: { type: String, default: 'md' },
color: { type: String, default: '#204E2B' }
})
const useFallback = ref(false)
/** Lucide 风格描边路径 */
const ICON_PATHS = {
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
minus: '<path d="M5 12h14"/>',
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
plus: '<path d="M5 12h14M12 5v14"/>',
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>'
}
/** 图片加载失败时的 emoji 回退 */
const ICON_FALLBACK = {
view: '👁',
ticket: '🎫',
calendar: '📅',
flame: '🔥',
'check-circle': '✓',
glucose: '💧',
heart: '❤',
activity: '🏃',
users: '👥',
'alert-triangle': '⚠',
minus: '—',
droplet: '💧',
sparkles: '✨',
trophy: '🏆',
share: '↗',
plus: '',
refresh: '↻',
volume: '🔊',
pause: '⏸',
play: '▶',
info: '!',
sun: '☀',
moon: '🌙',
sunset: '☀'
}
const strokeColor = computed(() => {
const c = String(props.color || '#204E2B').trim()
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
})
const iconSrc = computed(() => {
const path = ICON_PATHS[props.name] || ICON_PATHS.view
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
return svgToDataUrl(svg)
})
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
function onImageError() {
useFallback.value = true
}
</script>
<style lang="scss" scoped>
.tj-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tj-icon-wrap--sm {
width: 32rpx;
height: 32rpx;
}
.tj-icon-wrap--md {
width: 40rpx;
height: 40rpx;
}
.tj-icon-wrap--lg {
width: 48rpx;
height: 48rpx;
}
.tj-icon-wrap--xl {
width: 56rpx;
height: 56rpx;
}
.tj-icon {
display: block;
width: 100%;
height: 100%;
}
.tj-icon-fallback {
display: block;
line-height: 1;
font-weight: 700;
text-align: center;
}
.tj-icon-fallback--sm {
font-size: 24rpx;
}
.tj-icon-fallback--md {
font-size: 30rpx;
}
.tj-icon-fallback--lg {
font-size: 36rpx;
}
.tj-icon-fallback--xl {
font-size: 42rpx;
}
</style>
@@ -0,0 +1,115 @@
import { ref } from 'vue'
import { formatUserMessage } from '../utils/tongjiHelpers.js'
/** 登录 / 就诊卡门禁(index 与 more 共用) */
export function useTongjiAuth(proxy) {
const authChecking = ref(false)
let gateRedirected = false
let authSessionPromise = null
function hasAuthToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearAuthStorage() {
uni.removeStorageSync('token')
authSessionPromise = null
}
function wxLoginGetCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (res && res.code) resolve(res.code)
else reject(new Error('微信登录未返回 code'))
},
fail: reject
})
})
}
async function mnpLoginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res && res.code === 1 && res.data && res.data.token) {
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return res.data
}
clearAuthStorage()
throw new Error(formatUserMessage(res?.msg, '登录失败'))
}
async function doWxLogin() {
const code = await wxLoginGetCode()
await mnpLoginWithCode(code)
return hasAuthToken()
}
async function verifyOrLogin() {
if (!hasAuthToken()) {
return doWxLogin()
}
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res && res.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
} catch (e) {
/* 网络异常走重新登录 */
}
clearAuthStorage()
try {
return await doWxLogin()
} catch (e2) {
return false
}
}
async function ensureLoggedIn() {
if (authSessionPromise) {
return authSessionPromise
}
authSessionPromise = verifyOrLogin()
.then((ok) => {
if (!ok) authSessionPromise = null
return !!ok
})
.catch(() => {
authSessionPromise = null
return false
})
return authSessionPromise
}
function redirectToCardEntry(returnPath) {
if (gateRedirected) return
gateRedirected = true
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
uni.redirectTo({
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
})
}
function resetGateRedirect() {
gateRedirected = false
}
function isGateRedirected() {
return gateRedirected
}
return {
authChecking,
hasAuthToken,
ensureLoggedIn,
redirectToCardEntry,
resetGateRedirect,
isGateRedirected
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,993 @@
/**
* 日常记录统一主题 — 与甄养堂小程序 TCM Care 品牌色一致
* 来源:pages/index/index.vue · tabBar selectedColor #204e2b
*/
.daily-page {
/* 品牌主色 */
--primary: #204e2b;
--primary-dark: #163d22;
--primary-container: #386641;
--primary-grad: linear-gradient(160deg, #204e2b 0%, #386641 100%);
--primary-light: #eef6ef;
--primary-soft: #cfe8d3;
--primary-glow: rgba(32, 78, 43, 0.14);
--on-primary-container: #afe2b3;
--success: #386641;
--success-light: #eef6ef;
--danger: #dc2626;
--danger-light: #fef2f2;
--danger-glow: rgba(220, 38, 38, 0.1);
--warning: #d97706;
--warning-light: #fff7ed;
--text-primary: #1b1c1a;
--text-secondary: #414941;
--text-muted: #727970;
--surface: #ffffff;
--surface-muted: #f4f4f0;
--border: #e3e2df;
--border-soft: rgba(32, 78, 43, 0.12);
--slate-50: #faf9f5;
--slate-100: #f4f4f0;
--slate-200: #e9e8e4;
--slate-300: #e3e2df;
--slate-400: #727970;
--slate-600: #414941;
--slate-900: #1b1c1a;
--shadow-premium: 0 8rpx 28rpx rgba(32, 78, 43, 0.06);
--shadow-sm: 0 4rpx 16rpx rgba(32, 78, 43, 0.05);
--shadow-glow-primary: 0 8rpx 20rpx rgba(32, 78, 43, 0.18);
--shadow-glow-danger: 0 8rpx 20rpx rgba(220, 38, 38, 0.12);
--border-premium: 1rpx solid var(--border-soft);
background: var(--slate-50);
}
/* Hero:品牌绿渐变 */
.daily-page .hero-bg {
background: var(--primary-grad);
&::after {
background: radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.1) 0%, transparent 55%);
}
}
.daily-page .hero-orb {
display: none;
}
.daily-page .hero-pill.voice.speaking {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(255, 255, 255, 0.95);
animation: none;
box-shadow: 0 0 0 3rpx rgba(255, 255, 255, 0.35);
}
.daily-page .hero-status-chip.pending {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.28);
}
.daily-page .hero-status-chip.done {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.32);
}
/* 指标卡 */
.daily-page .stat-fasting,
.daily-page .stat-postprandial,
.daily-page .stat-other {
background: var(--surface);
}
.daily-page .stat-fasting .stat-card-tag,
.daily-page .stat-postprandial .stat-card-tag,
.daily-page .stat-other .stat-card-tag {
background: var(--primary-light);
color: var(--primary-dark);
}
.daily-page .stat-card-value {
background: none;
-webkit-background-clip: unset;
background-clip: unset;
color: var(--text-primary);
}
.daily-page .stat-card-value.is-high {
background: none;
color: var(--danger);
}
.daily-page .stat-card-trend.trend-down {
background: var(--primary-light);
.stat-card-trend-arrow,
.stat-card-trend-delta {
color: var(--primary-dark);
}
}
.daily-page .more-entry-arrow {
color: var(--primary);
}
.daily-page .card-chip.active {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .range-item.active {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .legend-dot-fasting {
background: var(--primary);
}
.daily-page .legend-dot-postprandial {
background: var(--warning);
}
.daily-page .streak-strip {
background: var(--surface);
border-color: var(--border-soft);
&::after {
background: radial-gradient(circle, rgba(32, 78, 43, 0.08) 0%, transparent 70%);
}
}
.daily-page .streak-num {
color: var(--primary-dark);
}
/* 日历:品牌绿深浅 */
.daily-page .calendar-cell.level-1 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-1 {
background: var(--primary-soft);
border-color: #b8dcc0;
}
.daily-page .calendar-cell.level-2 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-2 {
background: var(--on-primary-container);
border-color: #86c992;
}
.daily-page .calendar-cell.level-3 .calendar-cell-mark,
.daily-page .calendar-legend-cell.level-3 {
background: var(--primary);
border-color: var(--primary-dark);
}
.daily-page .day-block-tag.tag-blood,
.daily-page .day-block-tag.tag-diet,
.daily-page .day-block-tag.tag-exercise,
.daily-page .day-block-tag.tag-tracking {
background: var(--primary-light);
color: var(--primary-dark);
border: 1rpx solid var(--border-soft);
}
.daily-page .day-block-tag.tag-diet,
.daily-page .day-block-tag.tag-exercise {
background: var(--surface-muted);
color: var(--text-secondary);
border-color: var(--border);
}
.daily-page .pg-points-badge {
background: var(--primary-light);
border-color: var(--border-soft);
}
.daily-page .pg-points-num {
color: var(--primary-dark);
}
.daily-page .record-quick-chip.pending {
border-color: var(--border);
background: var(--surface-muted);
}
.daily-page .record-quick-chip.done {
border-color: rgba(56, 102, 65, 0.35);
background: var(--success-light);
}
.daily-page .task-action-btn.pending {
background: var(--primary);
}
.daily-page .water-btn {
background: var(--primary);
}
.daily-page .family-like-strip,
.daily-page .encourage-strip {
background: var(--surface);
border: 1rpx solid var(--border);
box-shadow: var(--shadow-premium);
}
.daily-page .card-title,
.daily-page .hero-title {
color: inherit;
}
.daily-page .report-share-btn,
.daily-page .report-share-btn::after {
background: var(--primary);
box-shadow: var(--shadow-glow-primary);
}
.daily-page .input-modal-btn.primary {
background: var(--primary);
}
.daily-page .input-field-dot-fasting {
background: var(--primary);
}
.daily-page .input-field-dot-postprandial {
background: var(--primary-container);
}
.daily-page .input-field-dot-other {
background: var(--text-muted);
}
.daily-page .voice-switch.on .voice-switch-track {
background: var(--primary);
}
.daily-page .heatmap-foot-cta {
background: var(--primary);
}
/* ========== 适老极简首页(index ========== */
.daily-page .elder-header {
padding: 48rpx 32rpx 24rpx;
}
.daily-page .elder-greet {
display: block;
font-size: 44rpx;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
}
.daily-page .elder-status {
display: block;
margin-top: 16rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1.4;
&.is-done {
color: var(--success);
}
&.is-pending {
color: var(--text-secondary);
}
}
.daily-page .elder-card-scroll {
margin-top: 24rpx;
width: 100%;
white-space: nowrap;
}
.daily-page .elder-card-row {
display: inline-flex;
flex-wrap: nowrap;
gap: 16rpx;
padding: 2rpx 0;
}
.daily-page .elder-card-chip {
flex-shrink: 0;
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: var(--surface);
border: 2rpx solid var(--border);
text {
font-size: 28rpx;
color: var(--text-secondary);
}
&.active {
background: var(--primary);
border-color: var(--primary);
text {
color: #fff;
font-weight: 600;
}
}
}
.daily-page .elder-actions {
padding: 0 32rpx 24rpx;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 16rpx;
}
.daily-page .elder-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
min-height: 112rpx;
min-width: 0;
border-radius: 24rpx;
box-shadow: var(--shadow-sm);
box-sizing: border-box;
}
.daily-page .elder-btn-text {
font-size: 36rpx;
font-weight: 700;
}
.daily-page .elder-btn-primary {
flex: 8;
background: var(--primary);
.elder-btn-text {
color: #fff;
}
}
.daily-page .elder-btn-voice {
flex: 2;
flex-direction: column;
gap: 8rpx;
padding: 12rpx 8rpx;
background: var(--surface);
border: 2rpx solid var(--border-soft);
.elder-btn-text {
color: var(--primary-dark);
font-size: 24rpx;
line-height: 1.25;
text-align: center;
}
&.speaking {
border-color: var(--primary);
background: var(--primary-light);
}
&.disabled {
opacity: 0.45;
}
}
.daily-page .elder-today-panel {
margin: 0 32rpx 24rpx;
padding: 32rpx;
background: var(--surface);
border-radius: 24rpx;
border: 1rpx solid var(--border);
box-shadow: var(--shadow-premium);
}
.daily-page .elder-panel-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24rpx;
}
.daily-page .elder-today-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
}
.daily-page .elder-metric {
padding: 24rpx;
border-radius: 20rpx;
background: var(--surface-muted);
border: 2rpx solid var(--border);
&.is-high {
border-color: rgba(220, 38, 38, 0.35);
background: var(--danger-light);
}
}
.daily-page .elder-metric-label {
display: block;
font-size: 28rpx;
color: var(--text-secondary);
margin-bottom: 12rpx;
}
.daily-page .elder-metric-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.daily-page .elder-metric-value {
font-size: 56rpx;
font-weight: 800;
color: var(--text-primary);
line-height: 1.1;
}
.daily-page .elder-metric.is-high .elder-metric-value {
color: var(--danger);
}
.daily-page .elder-metric-unit {
font-size: 26rpx;
color: var(--text-muted);
}
.daily-page .elder-metric-flag {
display: block;
margin-top: 12rpx;
font-size: 26rpx;
color: var(--danger);
font-weight: 600;
}
.daily-page .elder-more-link {
margin: 0 32rpx 16rpx;
padding: 28rpx 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface);
border-radius: 24rpx;
border: 1rpx solid var(--border);
}
.daily-page .elder-more-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: var(--text-primary);
}
.daily-page .elder-more-sub {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: var(--text-muted);
line-height: 1.45;
}
.daily-page .elder-more-arrow {
font-size: 48rpx;
color: var(--primary);
font-weight: 300;
padding-left: 16rpx;
}
.daily-page .footer-tip {
text-align: center;
font-size: 26rpx;
color: var(--text-muted);
padding: 16rpx 32rpx 48rpx;
line-height: 1.6;
}
.daily-page .elder-chart-block .range-bar {
margin-bottom: 16rpx;
}
.daily-page .glucose-history-block {
padding-bottom: 8rpx;
}
@@ -0,0 +1,49 @@
/**
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
*/
function utf8ToBytes(str) {
const encoded = encodeURIComponent(str)
const bytes = []
for (let i = 0; i < encoded.length; i++) {
if (encoded.charCodeAt(i) === 37) {
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
i += 2
} else {
bytes.push(encoded.charCodeAt(i))
}
}
return new Uint8Array(bytes)
}
function bytesToBase64(bytes) {
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
return uni.arrayBufferToBase64(bytes.buffer)
}
if (typeof btoa !== 'undefined') {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
let result = ''
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i]
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
result += chars[a >> 2]
result += chars[((a & 3) << 4) | (b >> 4)]
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
result += i + 2 < bytes.length ? chars[c & 63] : '='
}
return result
}
export function svgToDataUrl(svg) {
const normalized = String(svg || '').trim()
if (!normalized) return ''
const base64 = bytesToBase64(utf8ToBytes(normalized))
return `data:image/svg+xml;base64,${base64}`
}
@@ -0,0 +1,56 @@
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
export function formatUserMessage(msg, fallback = '') {
if (msg == null || msg === '') return fallback
if (typeof msg === 'string') return msg
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
if (Array.isArray(msg)) {
const parts = msg.map((item) => {
if (item == null) return ''
if (typeof item === 'string') return item
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
if (typeof item === 'object') {
return item.msg || item.message || item.error || item.title || item.label || ''
}
return String(item)
}).filter(Boolean)
return parts.length ? parts.join('') : fallback
}
if (typeof msg === 'object') {
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
}
return String(msg)
}
export function showUserToast(title, options = {}) {
const text = formatUserMessage(title, '')
if (!text) return
uni.showToast({
title: text,
icon: options.icon || 'none',
duration: options.duration
})
}
export function formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function parseDate(str) {
if (!str) return new Date()
const [y, m, d] = str.split('-').map(Number)
return new Date(y, (m || 1) - 1, d || 1)
}
export function toNumber(v) {
if (v === null || v === undefined || v === '') return null
const n = Number(v)
return Number.isFinite(n) && n !== 0 ? n : null
}
export function getWeekday(dateStr) {
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return w[parseDate(dateStr).getDay()]
}
@@ -0,0 +1,58 @@
/** 控糖树等级配置(前后端算法需保持一致:每级 50 积分,最高 Lv.9) */
export const TREE_MAX_LEVEL = 9
export const TREE_XP_PER_LEVEL = 50
export const TREE_LEVELS = [
{ level: 0, name: '种子眠', emoji: '🫘', mood: '困困', desc: '稳糖种子在土里打盹' },
{ level: 1, name: '破土芽', emoji: '🌱', mood: '探头', desc: '探出第一抹新绿' },
{ level: 2, name: '展两叶', emoji: '🌿', mood: '好奇', desc: '两片嫩叶迎风展' },
{ level: 3, name: '小树苗', emoji: '🪴', mood: '精神', desc: '身子骨硬朗起来' },
{ level: 4, name: '青枝繁', emoji: '🌳', mood: '茁壮', desc: '枝叶渐密,元气足' },
{ level: 5, name: '拔节高', emoji: '🌲', mood: '挺拔', desc: '一节一节往上蹿' },
{ level: 6, name: '稳糖冠', emoji: '💚', mood: '沉稳', desc: '树冠成形,习惯成自然' },
{ level: 7, name: '初绽香', emoji: '🌸', mood: '开心', desc: '枝头冒出第一朵花' },
{ level: 8, name: '漫开花', emoji: '🌺', mood: '灿烂', desc: '花开满枝,越记越稳' },
{ level: 9, name: '圆满树', emoji: '🏆', mood: '荣耀', desc: '满级大树,习惯大师' }
]
const WHISPERS_BY_LEVEL = {
0: ['种子在睡觉,记一笔就醒啦。', '今天浇第一滴水,芽就要冒出来。'],
1: ['破土啦!再坚持几天就长高。', '小芽最喜欢规律的记录了。'],
2: ['两片叶子为你鼓掌。', '空腹餐后都记全,我会长得更快。'],
3: ['我已经是一棵小树啦。', '家人点赞的时候,我也会发光。'],
4: ['枝叶越来越密,您真棒。', '连续记录,我会开出更多叶子。'],
5: ['拔节中!习惯比完美更重要。', '再浇一点水,我就更高啦。'],
6: ['习惯成自然,树冠成形啦。', '您今天的坚持,小树都记得。'],
7: ['开花啦!闻到春天的味道了吗?', '稳糖花只开给坚持的人。'],
8: ['满树花香,您已是控糖达人。', '明天继续来,花儿会更艳。'],
9: ['满级大树陪您一路稳糖。', '圆满不是终点,习惯才是。']
}
export function calcTreeFromPoints(points) {
const pts = Math.max(0, Number(points) || 0)
const level = Math.min(TREE_MAX_LEVEL, Math.floor(pts / TREE_XP_PER_LEVEL))
const xpInLevel = level >= TREE_MAX_LEVEL ? TREE_XP_PER_LEVEL : pts % TREE_XP_PER_LEVEL
const progress = level >= TREE_MAX_LEVEL ? 100 : Math.round((xpInLevel / TREE_XP_PER_LEVEL) * 100)
const meta = TREE_LEVELS[level] || TREE_LEVELS[0]
const nextMeta = level < TREE_MAX_LEVEL ? TREE_LEVELS[level + 1] : null
const pointsToNext = level >= TREE_MAX_LEVEL ? 0 : TREE_XP_PER_LEVEL - xpInLevel
return {
level,
progress,
name: meta.name,
emoji: meta.emoji,
mood: meta.mood,
desc: meta.desc,
xpInLevel,
xpNeed: TREE_XP_PER_LEVEL,
pointsToNext,
nextName: nextMeta?.name || '',
isMax: level >= TREE_MAX_LEVEL
}
}
export function pickTreeWhisper(level) {
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
const list = WHISPERS_BY_LEVEL[lv] || WHISPERS_BY_LEVEL[0]
return list[Math.floor(Math.random() * list.length)]
}
@@ -0,0 +1,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

+2 -1
View File
@@ -1,4 +1,5 @@
NODE_ENV = 'development'
# Base API
# 后端接口根地址(含协议与端口),例如 http://127.0.0.1:8080
# 相对路径 /uploads/… 若此项为完整 URL,会与后端对齐;未填时由 vite 将 /uploads 代理到默认 http://127.0.0.1:8080。
VITE_APP_BASE_URL=''
+7
View File
@@ -24,6 +24,7 @@
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
"highlight.js": "^11.11.1",
"hls.js": "^1.6.16",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lodash": "^4.17.21",
@@ -8264,6 +8265,12 @@
"node": ">=12.0.0"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-void-elements": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
+1
View File
@@ -26,6 +26,7 @@
"echarts": "^5.6.0",
"element-plus": "^2.9.4",
"highlight.js": "^11.11.1",
"hls.js": "^1.6.16",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lodash": "^4.17.21",
+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 })
}
+10
View File
@@ -66,6 +66,16 @@ export function appointmentLists(params: any) {
return request.get({ url: '/doctor.appointment/lists', params })
}
/** 后台编辑挂号(预约日期/时段/类型/状态/备注/医助) */
export function appointmentAdminEdit(params: any) {
return request.post({ url: '/doctor.appointment/edit', params })
}
/** 批量修改挂号渠道来源(与 edit 共用 doctor.appointment/edit 权限) */
export function appointmentBatchEditChannel(params: any) {
return request.post({ url: '/doctor.appointment/batchEditChannel', params })
}
// 获取挂号详情
export function appointmentDetail(params: any) {
return request.get({ url: '/doctor.appointment/detail', params })
+5
View File
@@ -104,3 +104,8 @@ export function orderActionLogStats(params?: { start_time?: string; end_time?: s
export function orderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
return request.post({ url: '/order.order/assignAssistant', params })
}
/** 设置/取消支付单豁免权 */
export function orderSetExempt(params: { id: number; is_exempt: 0 | 1 }) {
return request.post({ url: '/order.order/setExempt', 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 })
}
+85
View File
@@ -103,3 +103,88 @@ export function doctorDailyStatsOverview(params: {
}) {
return request.get({ url: '/stats.doctorDailyStats/overview', params })
}
/** 提成结算接口计算较重,单独放宽超时(默认多为 60s) */
const COMMISSION_SETTLEMENT_TIMEOUT_MS = 120000
/** 提成结算业绩(独立于业绩看板 yejiStats) */
export function commissionSettlementOverview(params: {
settlement_month: string
/** 与 tcm.prescriptionOrder/lists 同源:create_time between */
start_time?: string
end_time?: string
/** 默认 3=履约完成,与列表 fulfillment_status 一致 */
fulfillment_status?: number
/** 传 1 时仅统计 is_system_auto=1;显式时段下默认不传(含手动) */
require_system_auto_prescription?: 0 | 1
dept_ids?: number[] | string
channel_code?: string
}) {
return request.get(
{ url: '/stats.commissionSettlement/overview', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
export function commissionSettlementDeptOptions() {
return request.get({ url: '/stats.commissionSettlement/deptOptions' })
}
export function commissionSettlementChannelOptions() {
return request.get({ url: '/stats.commissionSettlement/channelOptions' })
}
/** 提成核对:订单明细分页 bucket: 空|current|deferredappt_channel_value 可与 assistant_id/doctor_id 组合(0=未匹配挂号渠道) */
export function commissionSettlementOrderLines(params: {
settlement_month: string
start_time?: string
end_time?: string
fulfillment_status?: number
require_system_auto_prescription?: 0 | 1
dept_ids?: number[] | string
channel_code?: string
page?: number
page_size?: number
bucket?: string
assistant_id?: number
doctor_id?: number
appt_channel_value?: number
}) {
return request.get(
{ url: '/stats.commissionSettlement/orderLines', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
export function commissionSettlementConfirmStatus(params: {
settlement_month: string
dept_ids?: number[] | string
channel_code?: string
}) {
return request.get({ url: '/stats.commissionSettlement/confirmStatus', params })
}
export function commissionSettlementSaveReconcile(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/saveReconcile', params })
}
export function commissionSettlementConfirmFinalize(params: Record<string, any>) {
return request.post(
{ url: '/stats.commissionSettlement/confirmFinalize', params, timeout: COMMISSION_SETTLEMENT_TIMEOUT_MS },
{ ignoreCancelToken: true }
)
}
/** 撤回「确定本期业绩」:清除顺延结转,状态变为可再次核对/确定 */
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 })
}
+32 -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 })
@@ -363,6 +373,11 @@ export function prescriptionOrderLists(params: any) {
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
}
/** 处方业务订单导出(export=1 预估条数,export=2 下载 Excel */
export function prescriptionOrderExport(params: any) {
return request.get({ url: '/tcm.prescriptionOrder/export', params })
}
/**
* 诊单下可关联的支付单(已支付 zyt_order;已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选)。
* 服务端仅返回创建时间在 2026-04-20(含)之后的支付单;编辑时本单已关联的旧单仍会出现在列表中。
@@ -447,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 })
@@ -485,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 })
}
// ========== 处方库 ==========
// 处方库列表
@@ -13,6 +13,7 @@
<el-button>导出</el-button>
</template>
<div>
<p v-if="props.exportHint" class="text-sm text-gray-500 mb-3 leading-relaxed">{{ props.exportHint }}</p>
<el-form ref="formRef" :model="formData" label-width="120px" :rules="formRules">
<el-form-item label="数据量:">
预计导出{{ exportData.count }}条数据 {{ exportData.sum_page }}每页{{
@@ -79,6 +80,11 @@ const props = defineProps({
fetchFun: {
type: Function,
required: true
},
/** 可选:导出弹窗内提示文案(如说明与列表筛选一致) */
exportHint: {
type: String,
default: ''
}
})
const popupRef = shallowRef<InstanceType<typeof Popup>>()
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
+17 -2
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { getConfig } from '@/api/app'
import configs from '@/config'
interface AppSate {
config: Record<string, any>
@@ -26,8 +27,22 @@ const useAppStore = defineStore({
getImageUrl(url: string) {
if (!url || typeof url !== 'string') return ''
if (url.startsWith('http://') || url.startsWith('https://')) return url
const domain = this.config.oss_domain || (typeof window !== 'undefined' ? window.location.origin + '/' : '')
return domain ? `${domain.replace(/\/$/, '')}${url.startsWith('/') ? url : '/' + url}` : url
const path = url.startsWith('/') ? url : '/' + url
const oss = String(this.config.oss_domain || '').replace(/\/$/, '')
if (oss) {
return `${oss}${path}`
}
// 开发环境:后台接口与 Vite 不同端口时,相对路径不能拼到 location.origin(应对接到 php 静态域名)
const apiRoot = String(configs.baseUrl || '/').replace(/\/+$/, '')
if (apiRoot.startsWith('http://') || apiRoot.startsWith('https://')) {
try {
return new URL(path, `${apiRoot}/`).href
} catch {
return `${apiRoot}${path}`
}
}
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return origin ? `${origin}${path}` : path
},
getConfig() {
return new Promise((resolve, reject) => {
+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>
@@ -0,0 +1,582 @@
<template>
<div class="guahao-list">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" :inline="true" @submit.prevent>
<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-input
v-model="queryParams.patient_name"
placeholder="模糊搜索"
clearable
class="!w-[160px]"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="医生">
<el-input
v-model="queryParams.doctor_name"
placeholder="模糊搜索"
clearable
class="!w-[140px]"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="医助">
<el-select
v-model="queryParams.assistant_id"
placeholder="全部"
clearable
filterable
class="!w-[180px]"
>
<el-option
v-for="a in assistantOptions"
:key="a.id"
:label="a.name + (a.account ? ` (${a.account})` : '')"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
<el-option label="已预约" :value="1" />
<el-option label="已取消" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已过号" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-select
v-model="queryParams.channel_source"
placeholder="全部"
clearable
filterable
class="!w-[200px]"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-button
v-perms="['doctor.appointment/edit']"
type="primary"
:disabled="selectedRows.length === 0"
@click="openBatchChannelDialog"
>
批量修改渠道
</el-button>
<span v-if="selectedRows.length > 0" class="text-sm text-gray-500">
已选 {{ selectedRows.length }}
</span>
</div>
<el-table
ref="tableRef"
v-loading="pager.loading"
row-key="id"
:data="pager.lists"
size="large"
stripe
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" align="center" reserve-selection />
<el-table-column label="ID" prop="id" width="72" align="center" />
<el-table-column label="诊单/患者" min-width="140">
<template #default="{ row }">
<div class="text-sm">
<div class="font-medium">{{ row.patient_name || '—' }}</div>
<div class="text-gray-500">{{ maskPhone(row.patient_phone) }}</div>
<div class="text-xs text-gray-400">诊单 #{{ row.diagnosis_id ?? row.patient_id }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="医生" prop="doctor_name" width="100" show-overflow-tooltip />
<el-table-column label="医助" prop="assistant_name" width="100" show-overflow-tooltip />
<el-table-column label="预约时间" min-width="128">
<template #default="{ row }">
<div>{{ row.appointment_date }}</div>
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
</template>
</el-table-column>
<el-table-column label="类型" prop="appointment_type_desc" width="100" />
<el-table-column label="渠道" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
<div v-if="row.channel_source_detail" class="text-xs text-gray-400 truncate">
{{ row.channel_source_detail }}
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="{ row }">
<el-tag
:type="
row.status === 1 ? 'success' : row.status === 2 ? 'info' : row.status === 3 ? 'primary' : 'danger'
"
size="small"
effect="light"
>
{{ row.status_desc }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button v-perms="['doctor.appointment/edit']" type="primary" link @click="openEdit(row)">
编辑
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<el-dialog v-model="editVisible" title="编辑挂号" width="560px" destroy-on-close @closed="resetEditForm">
<el-form :model="editForm" label-width="96px">
<el-form-item label="预约日期" required>
<el-date-picker
v-model="editForm.appointment_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="!w-full"
/>
</el-form-item>
<el-form-item label="预约时间" required>
<el-time-picker
v-model="editForm.appointment_time"
format="HH:mm"
value-format="HH:mm"
placeholder="时间"
class="!w-full"
/>
</el-form-item>
<el-form-item label="时段" required>
<el-radio-group v-model="editForm.period">
<el-radio-button label="morning">上午</el-radio-button>
<el-radio-button label="afternoon">下午</el-radio-button>
<el-radio-button label="all">全天</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="问诊类型" required>
<el-select v-model="editForm.appointment_type" class="!w-full">
<el-option label="视频问诊" value="video" />
<el-option label="图文问诊" value="text" />
<el-option label="电话问诊" value="phone" />
</el-select>
</el-form-item>
<el-form-item label="渠道来源" required>
<el-select
v-model="editForm.channel_source"
placeholder="请选择渠道来源"
filterable
class="!w-full"
@change="onChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="needsChannelSourceDetail" label="自媒体补充" required>
<el-input
v-model="editForm.channel_source_detail"
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
clearable
class="!w-full"
/>
</el-form-item>
<el-form-item label="状态" required>
<el-select v-model="editForm.status" class="!w-full">
<el-option label="已预约" :value="1" />
<el-option label="已取消" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已过号" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="挂号医助">
<el-select
v-model="editForm.assistant_id"
placeholder="不修改可留空"
clearable
filterable
class="!w-full"
>
<el-option
v-for="a in assistantOptions"
:key="a.id"
:label="a.name + (a.account ? ` (${a.account})` : '')"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog
v-model="batchChannelVisible"
title="批量修改渠道"
width="520px"
destroy-on-close
@closed="resetBatchChannelForm"
>
<p class="text-sm text-gray-600 mb-4">将对已选 {{ selectedRows.length }} 条挂号写入同一渠道来源仅改渠道不改其它字段</p>
<el-form :model="batchChannelForm" label-width="96px">
<el-form-item label="渠道来源" required>
<el-select
v-model="batchChannelForm.channel_source"
placeholder="请选择渠道来源"
filterable
class="!w-full"
@change="onBatchChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="needsBatchChannelSourceDetail" label="自媒体补充" required>
<el-input
v-model="batchChannelForm.channel_source_detail"
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
clearable
class="!w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchChannelVisible = false">取消</el-button>
<el-button type="primary" :loading="batchChannelSaving" @click="submitBatchChannel">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup name="consumerPrescriptionGuahao">
import DaterangePicker from '@/components/daterange-picker/index.vue'
import { getDictData } from '@/api/app'
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
import { getAssistants } from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
const queryParams = reactive({
start_date: '',
end_date: '',
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
status: '' as number | '',
channel_source: '' as string
})
const queryInit = {
start_date: '',
end_date: '',
patient_name: '',
doctor_name: '',
assistant_id: undefined as number | undefined,
status: '' as number | '',
channel_source: ''
}
const { pager, getLists, resetPage } = usePaging({
fetchFun: appointmentLists,
params: queryParams
})
const assistantOptions = ref<Array<{ id: number; name: string; account?: string }>>([])
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
const selectedRows = ref<Record<string, unknown>[]>([])
function onSelectionChange(rows: Record<string, unknown>[]) {
selectedRows.value = rows
}
/** 与诊单预约弹窗一致:这些字典 name 需填「自媒体补充」 */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q'
])
function channelNameRequiresSelfMediaDetail(name: string) {
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
}
const channelOptions = ref<Array<{ name: string; value: string }>>([])
const selectedChannelDictName = computed(() => {
const v = editForm.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
const needsChannelSourceDetail = computed(() => channelNameRequiresSelfMediaDetail(selectedChannelDictName.value))
const batchSelectedChannelDictName = computed(() => {
const v = batchChannelForm.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
const needsBatchChannelSourceDetail = computed(() =>
channelNameRequiresSelfMediaDetail(batchSelectedChannelDictName.value)
)
function onBatchChannelSourceChange(val: string) {
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
batchChannelForm.channel_source_detail = ''
}
}
const batchChannelVisible = ref(false)
const batchChannelSaving = ref(false)
const batchChannelForm = reactive({
channel_source: '' as string,
channel_source_detail: '' as string
})
function openBatchChannelDialog() {
if (selectedRows.value.length === 0) {
feedback.msgWarning('请先勾选挂号记录')
return
}
batchChannelForm.channel_source = ''
batchChannelForm.channel_source_detail = ''
batchChannelVisible.value = true
}
function resetBatchChannelForm() {
batchChannelForm.channel_source = ''
batchChannelForm.channel_source_detail = ''
}
async function submitBatchChannel() {
if (selectedRows.value.length === 0) {
feedback.msgWarning('请先勾选挂号记录')
return
}
if (!batchChannelForm.channel_source) {
feedback.msgError('请选择渠道来源')
return
}
if (needsBatchChannelSourceDetail.value && !batchChannelForm.channel_source_detail.trim()) {
feedback.msgError('请填写自媒体补充说明')
return
}
batchChannelSaving.value = true
try {
const ids = selectedRows.value.map((r) => Number(r.id))
await appointmentBatchEditChannel({
ids,
channel_source: batchChannelForm.channel_source,
channel_source_detail: batchChannelForm.channel_source_detail.trim()
})
feedback.msgSuccess('批量修改成功')
batchChannelVisible.value = false
tableRef.value?.clearSelection?.()
selectedRows.value = []
getLists()
} finally {
batchChannelSaving.value = false
}
}
function onChannelSourceChange(val: string) {
const row = channelOptions.value.find((item: { value: string }) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
editForm.channel_source_detail = ''
}
}
const editVisible = ref(false)
const editSaving = ref(false)
const editForm = reactive({
id: 0,
appointment_date: '',
appointment_time: '' as string,
period: 'morning' as 'morning' | 'afternoon' | 'all',
appointment_type: 'video',
status: 1,
remark: '',
assistant_id: undefined as number | undefined,
channel_source: '' as string,
channel_source_detail: '' as string
})
function maskPhone(phone?: string) {
const p = String(phone || '')
if (p.length >= 11) {
return p.slice(0, 3) + '****' + p.slice(-4)
}
return p || '—'
}
function formatHm(t?: string) {
const s = String(t || '')
return s.length >= 5 ? s.slice(0, 5) : s || '—'
}
function resetFilter() {
Object.assign(queryParams, queryInit)
resetPage()
}
function rowPeriod(row: Record<string, unknown>): 'morning' | 'afternoon' | 'all' {
const v = String(row.period ?? row.type ?? 'morning')
if (v === 'afternoon' || v === 'all') {
return v
}
return 'morning'
}
async function loadAssistants() {
try {
const res: any = await getAssistants()
assistantOptions.value = res?.lists ?? res ?? []
} catch {
assistantOptions.value = []
}
}
async function loadChannelOptions() {
try {
const data: any = await getDictData({ type: 'channels' })
const rows = (data?.channels || []).filter((row: { status?: number }) => row.status !== 0)
rows.sort((a: { sort?: number; id?: number }, b: { sort?: number; id?: number }) => {
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
if (ds !== 0) return ds
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
})
channelOptions.value = rows
} catch {
channelOptions.value = []
}
}
function openEdit(row: Record<string, unknown>) {
editForm.id = Number(row.id)
editForm.appointment_date = String(row.appointment_date || '')
editForm.appointment_time = formatHm(String(row.appointment_time || ''))
editForm.period = rowPeriod(row)
editForm.appointment_type = String(row.appointment_type || 'video')
editForm.status = Number(row.status)
editForm.remark = String(row.remark || '')
const raw = row.appointment_assistant_id
editForm.assistant_id =
raw !== undefined && raw !== null && raw !== '' ? Number(raw) : undefined
editForm.channel_source = String(row.channel_source ?? row.channels ?? '')
editForm.channel_source_detail = String(row.channel_source_detail ?? '')
editVisible.value = true
}
function resetEditForm() {
editForm.id = 0
editForm.appointment_date = ''
editForm.appointment_time = ''
editForm.period = 'morning'
editForm.appointment_type = 'video'
editForm.status = 1
editForm.remark = ''
editForm.assistant_id = undefined
editForm.channel_source = ''
editForm.channel_source_detail = ''
}
async function submitEdit() {
if (!editForm.appointment_date) {
feedback.msgError('请选择预约日期')
return
}
if (!editForm.appointment_time) {
feedback.msgError('请选择预约时间')
return
}
if (!editForm.channel_source) {
feedback.msgError('请选择渠道来源')
return
}
if (needsChannelSourceDetail.value && !editForm.channel_source_detail.trim()) {
feedback.msgError('请填写自媒体补充说明')
return
}
editSaving.value = true
try {
const payload: Record<string, unknown> = {
id: editForm.id,
appointment_date: editForm.appointment_date,
appointment_time: editForm.appointment_time,
period: editForm.period,
appointment_type: editForm.appointment_type,
status: editForm.status,
remark: editForm.remark,
assistant_id: editForm.assistant_id ?? '',
channel_source: editForm.channel_source,
channel_source_detail: editForm.channel_source_detail.trim()
}
await appointmentAdminEdit(payload)
feedback.msgSuccess('保存成功')
editVisible.value = false
getLists()
} finally {
editSaving.value = false
}
}
onMounted(() => {
loadAssistants()
loadChannelOptions()
})
onActivated(() => {
getLists()
})
getLists()
</script>
File diff suppressed because it is too large Load Diff
@@ -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"
@@ -925,11 +941,12 @@
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
@@ -982,11 +999,12 @@
<span class="text-red-500">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">
{{ row.status_desc || orderStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
@@ -2438,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 且不具备处方审核角色仅能用支付单审核筛选
*/
@@ -2671,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 = [
@@ -2765,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()) {
@@ -2787,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
@@ -2948,6 +3021,7 @@ function handleReset() {
queryParams.prescription_audit_status = ''
queryParams.payment_slip_audit_status = ''
queryParams.supply_mode = ''
queryParams.audit_admin_id = ''
resetParams()
}
@@ -3045,19 +3119,57 @@ 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
}) {
// (5)(6)(3)(4)
const fs = Number(row.fulfillment_status)
if (fs !== 1 && fs !== 2) return false
// > 0
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)
if (gcNo !== '' || gcTime > 0) return false
return true
const gcLocked = gcNo !== '' || gcTime > 0
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
return fs === 1 || fs === 2
}
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
@@ -3126,9 +3238,7 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
// (5) / (6) 使
const fs = Number(row.fulfillment_status)
return fs === 5 || fs === 6
return Number(row.fulfillment_status) === 5
}
function canUploadGancaoRow(row: {
@@ -3402,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)
@@ -3496,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
}
}
}
@@ -3639,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 = []
@@ -3655,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)
@@ -3885,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
@@ -4310,8 +4481,8 @@ async function submitQuickTrack() {
if (nd) detailData.value = nd
} catch { /* 静默 */ }
}
//
if (canShipRow({ fulfillment_status: 2, tracking_number: quickTrackForm.tracking_number })) {
// (2)(5)/(6)
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
try {
await ElMessageBox.confirm(
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -102,6 +102,18 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="添加时间">
<el-date-picker
v-model="addTimeRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
clearable
class="!w-[280px]"
@change="onAddTimeRangeChange"
/>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="queryParams.tag_ids"
@@ -557,12 +569,34 @@ const syncSettings = reactive({
interval: 3600
})
const queryParams = reactive<{ name: string; follow_user: string; tag_ids: string[] }>({
const queryParams = reactive<{
name: string
follow_user: string
tag_ids: string[]
add_time_start: string
add_time_end: string
}>({
name: '',
follow_user: '',
tag_ids: []
tag_ids: [],
add_time_start: '',
add_time_end: ''
})
/** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time */
const addTimeRange = ref<[string, string] | null>(null)
function onAddTimeRangeChange(val: [string, string] | null) {
if (val && val.length === 2 && val[0] && val[1]) {
queryParams.add_time_start = val[0]
queryParams.add_time_end = val[1]
} else {
queryParams.add_time_start = ''
queryParams.add_time_end = ''
}
resetPage()
}
// +
interface TagItem {
tag_id: string
@@ -793,6 +827,9 @@ function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.tag_ids = []
queryParams.add_time_start = ''
queryParams.add_time_end = ''
addTimeRange.value = null
resetParams()
}
File diff suppressed because it is too large Load Diff
+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>
+25 -2
View File
@@ -180,11 +180,12 @@
</template>
</el-table-column>
<el-table-column label="订单状态" width="100">
<el-table-column label="订单状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusTag(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
@@ -270,6 +271,14 @@
>
删除
</el-button>
<el-button
v-perms="['order.order/setExempt']"
:type="row.is_exempt === 1 ? 'warning' : 'default'"
link
@click="handleToggleExempt(row)"
>
{{ row.is_exempt === 1 ? '取消豁免' : '设为豁免' }}
</el-button>
</template>
</el-table-column>
</el-table>
@@ -779,7 +788,8 @@ import {
orderActionLogs,
orderActionLogStats,
orderBatchAssignAssistant,
orderSplit
orderSplit,
orderSetExempt
} from '@/api/order'
import { getAssistants } from '@/api/tcm'
import { generateOrderQrcode } from '@/api/tcm'
@@ -1494,6 +1504,19 @@ const handleDelete = async (row: any) => {
}
}
const handleToggleExempt = async (row: any) => {
const newVal: 0 | 1 = row.is_exempt === 1 ? 0 : 1
const actionText = newVal === 1 ? '设为豁免' : '取消豁免'
try {
await feedback.confirm(`确定要${actionText}该支付单吗?豁免后关联此单的业务订单将跳过定金门槛校验。`)
await orderSetExempt({ id: row.id, is_exempt: newVal })
feedback.msgSuccess(`${actionText}成功`)
getLists()
} catch (error) {
//
}
}
//
const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false)
@@ -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>
+72 -5
View File
@@ -49,15 +49,30 @@
v-model="form.channel_source"
placeholder="请选择渠道来源"
class="channel-source-select"
@change="onChannelSourceChange"
>
<el-option
v-for="item in channelOptions"
:key="item.value"
:key="String(item.value)"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="needsChannelSourceDetail"
label="自媒体补充:"
required
>
<el-input
v-model="form.channel_source_detail"
clearable
maxlength="128"
show-word-limit
placeholder="请输入自媒体相关补充内容"
class="channel-source-select"
/>
</el-form-item>
<!-- 预约医生 -->
<el-form-item label="预约医生:">
<div class="doctor-list">
@@ -233,9 +248,44 @@ const form = reactive({
date: '',
appointmentTime: '',
remark: '',
channel_source: '' as string
channel_source: '' as string,
channel_source_detail: '' as string
})
/** 仅这些渠道字典 name 需填「自媒体补充」(与后台字典名称完全一致) */
const CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL = new Set([
'自媒体4H',
'自媒体3Q',
'自媒体3H',
'自媒体2H',
'自媒体2Q'
])
function channelNameRequiresSelfMediaDetail(name: string) {
return CHANNEL_NAMES_REQUIRING_SELF_MEDIA_DETAIL.has(String(name ?? '').trim())
}
/** 当前选中渠道字典名称(用于判断是否需填「自媒体补充」) */
const selectedChannelDictName = computed(() => {
const v = form.channel_source
if (v === '' || v == null) return ''
const row = channelOptions.value.find((item: any) => String(item.value) === String(v))
return row ? String(row.name ?? '').trim() : ''
})
/** 仅白名单内字典项需填写补充说明 */
const needsChannelSourceDetail = computed(() =>
channelNameRequiresSelfMediaDetail(selectedChannelDictName.value)
)
const onChannelSourceChange = (val: string) => {
const row = channelOptions.value.find((item: any) => String(item.value) === String(val))
const name = row ? String(row.name ?? '').trim() : ''
if (!channelNameRequiresSelfMediaDetail(name)) {
form.channel_source_detail = ''
}
}
// 7
const dateOptions = computed<DateOption[]>(() => {
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
@@ -321,7 +371,8 @@ const canSubmit = computed(() => {
selectedDoctorId.value &&
form.date &&
form.appointmentTime &&
!!form.channel_source
!!form.channel_source &&
(!needsChannelSourceDetail.value || form.channel_source_detail.trim() !== '')
)
})
@@ -344,6 +395,7 @@ const open = async (patient: any) => {
form.appointmentTime = ''
form.remark = ''
form.channel_source = ''
form.channel_source_detail = ''
timeSlots.value = []
doctorRosterDates.value = []
lastVisit.value = ''
@@ -412,7 +464,17 @@ const loadLastVisit = async () => {
const loadChannelOptions = async () => {
try {
const data = await getDictData({ type: 'channels' })
channelOptions.value = (data?.channels || []).filter((row: any) => row.status !== 0)
const rows = (data?.channels || []).filter((row: any) => row.status !== 0)
rows.sort((a: any, b: any) => {
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
if (ds !== 0) return ds
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
})
// value el-select
channelOptions.value = rows.map((row: any) => ({
...row,
value: row?.value != null && row.value !== '' ? String(row.value) : ''
}))
} catch (e) {
console.error('加载渠道来源失败:', e)
channelOptions.value = []
@@ -612,6 +674,10 @@ const handleConfirm = async () => {
feedback.msgWarning('请选择渠道来源')
return
}
if (needsChannelSourceDetail.value && !form.channel_source_detail.trim()) {
feedback.msgWarning('请填写自媒体渠道补充信息')
return
}
if (blockedByTodayDuplicate.value) {
feedback.msgWarning(
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
@@ -631,7 +697,8 @@ const handleConfirm = async () => {
appointment_time: form.appointmentTime,
appointment_type: form.appointmentType,
remark: form.remark,
channel_source: form.channel_source
channel_source: String(form.channel_source ?? ''),
channel_source_detail: form.channel_source_detail.trim()
}
console.log('提交预约参数:', params)
@@ -105,7 +105,9 @@ function channelLabel(row: Record<string, any>) {
const raw = row.channel_source ?? row.channels ?? ''
if (raw === '' || raw === null || raw === undefined) return '—'
const key = String(raw)
return channelMap.value[key] || key
const base = channelMap.value[key] || key
const extra = String(row.channel_source_detail ?? '').trim()
return extra !== '' ? `${base}${extra}` : base
}
function sortRows(list: any[]) {
@@ -141,6 +143,8 @@ const load = async () => {
await loadChannels()
const res = await appointmentLists({
patient_id: props.diagnosisId,
/** 诊单维度拉挂号:后端豁免医生/医助与数据范围收窄 */
diag_scope_relax: 1,
page_no: 1,
page_size: 500
})
@@ -1,6 +1,6 @@
<template>
<div class="call-record-panel">
<div class="call-record-toolbar">
<div v-if="!readOnly" class="call-record-toolbar">
<upload
ref="toolbarUploadRef"
type="video"
@@ -19,10 +19,19 @@
<div class="call-record-empty">
<div class="call-record-empty__title">暂无通话记录</div>
<div class="call-record-empty__desc">
现在可以直接点击上方上传视频系统会自动生成一条默认通话记录来承载回放
{{
readOnly
? '暂无录制回放数据。'
: '现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。'
}}
</div>
</div>
</template>
<el-table-column label="录制回放" min-width="320">
<template #default="{ row }">
<RecordingPlaybackBlock :record-id="row.id" :urls="row.recording_urls_list" />
</template>
</el-table-column>
<el-table-column label="开始时间" width="170" prop="start_time_text" />
<el-table-column label="结束时间" width="170" prop="end_time_text" />
<el-table-column label="通话类型" width="100">
@@ -47,7 +56,7 @@
{{ row.recording_status_text || '—' }}
</template>
</el-table-column>
<el-table-column label="上传回放" width="180">
<el-table-column v-if="!readOnly" label="上传回放" width="180">
<template #default="{ row }">
<upload
type="video"
@@ -61,34 +70,12 @@
</upload>
</template>
</el-table-column>
<el-table-column label="录制回放" min-width="320">
<template #default="{ row }">
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
<div
v-for="(url, idx) in row.recording_urls_list"
:key="`${row.id}-${idx}`"
class="recording-item"
>
<video
v-if="isPlayableVideo(url)"
:src="url"
controls
preload="metadata"
class="recording-video"
/>
<el-link v-else :href="url" target="_blank" type="primary">
打开链接 {{ idx + 1 }}
</el-link>
</div>
</div>
<span v-else class="text-gray-400">暂无</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
@@ -106,9 +93,14 @@ type CallRecordRow = {
duration_text?: string
}
const props = defineProps<{
diagnosisId: number
}>()
const props = withDefaults(
defineProps<{
diagnosisId: number
/** 只读场景(如诊单只读详情):不展示任何上传入口 */
readOnly?: boolean
}>(),
{ readOnly: false }
)
const loading = ref(false)
const rows = ref<CallRecordRow[]>([])
@@ -147,11 +139,6 @@ function statusText(status: number) {
return m[status] ?? '—'
}
function isPlayableVideo(url: string) {
if (!url || typeof url !== 'string') return false
return /\.(mp4|webm|ogg|mov|mkv)(\?|$)/i.test(url)
}
async function handleToolbarUploadSuccess(response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
@@ -223,24 +210,5 @@ function normalizeUploadUrl(response: any): string {
font-size: 12px;
line-height: 1.6;
}
.recording-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.recording-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.recording-video {
max-width: 100%;
max-height: 180px;
border-radius: 4px;
background: #000;
}
}
</style>
@@ -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
})
@@ -683,7 +713,7 @@ const bloodTrendOption = computed(() => ({
name: '空腹血糖',
type: 'line',
smooth: true,
connectNulls: false,
connectNulls: true,
data: bloodTrendSeries.value.fasting,
symbolSize: 7,
itemStyle: {
@@ -698,7 +728,7 @@ const bloodTrendOption = computed(() => ({
name: '餐后血糖',
type: 'line',
smooth: true,
connectNulls: false,
connectNulls: true,
data: bloodTrendSeries.value.postprandial,
symbolSize: 7,
itemStyle: {
@@ -719,38 +749,44 @@ function formatColumnDate(date: string) {
function parseTrendNumber(value: unknown): number | null {
if (value === '' || value == null) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
if (!Number.isFinite(parsed) || parsed === 0) return 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 {
@@ -1114,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 {
@@ -1121,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;
}
@@ -3,7 +3,7 @@
<div class="toolbar">
<el-button
type="primary"
:disabled="!props.diagnosisId || !!props.readOnly || !hasDailyRecordPermission"
:disabled="!props.diagnosisId || !hasDailyRecordPermission"
@click="handleAdd"
v-perms="DAILY_RECORD_PERMS"
>
@@ -226,7 +226,7 @@ const disablePastDate = (date: Date) => {
}
const handleAdd = () => {
if (!props.diagnosisId || props.readOnly || !hasDailyRecordPermission.value) {
if (!props.diagnosisId || !hasDailyRecordPermission.value) {
feedback.msgWarning('诊单未保存,无法添加待办')
return
}
@@ -39,19 +39,315 @@
{{ formatTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['tcm.prescriptionOrder/detail']"
type="primary"
link
@click="openDetail(row.id)"
>查看详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-3">
<pagination v-model="pager" @change="getLists" />
</div>
</template>
<!-- 业务订单详情抽屉只读 order_list.vue 详情保持一致 -->
<el-drawer
v-model="detailVisible"
size="80%"
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<template #header>
<div class="flex items-center">
<span class="font-semibold text-lg">业务订单详情</span>
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
</div>
</template>
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
<!-- 顶部数据概览 -->
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">总金额</div>
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付总额</div>
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">需代收</div>
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</el-col>
</el-row>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 左侧处方单不含测试价格改患者信息药材明细 -->
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">处方详情</span>
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
开方人<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
</span>
</div>
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
</div>
</template>
<template v-if="detailPrescription">
<el-descriptions :column="2" border size="small" class="bg-white">
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
<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" v-if="showPrescriptionAuditFilter">
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
</el-descriptions-item>
<el-descriptions-item label="处方金额">
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="消费者审核">
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</el-descriptions-item>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
</div>
<div>
<span class="text-gray-500">订单设置</span>
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</template>
<el-empty v-else description="无处方数据" :image-size="72" />
</el-card>
<!-- 右侧未关联 + 已关联支付单 -->
<div class="flex flex-col gap-4 h-full">
<el-card shadow="never" class="border-gray-100">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium text-[15px]">未关联的收款记录</span>
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
</div>
</template>
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="无未关联的收款单" :image-size="60" />
</el-card>
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">关联收款记录</span>
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
</div>
</template>
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="未关联收款单" :image-size="60" />
</el-card>
</div>
</div>
<!-- 履约与收货信息 -->
<el-card shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="业务订单创建人" :span="2">
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="创建人所属部门" :span="3">
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
<el-descriptions-item label="收货手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
<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>
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '' }}</el-descriptions-item>
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 物流轨迹只读 -->
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" class="w-[180px]" @keyup.enter="fetchLogisticsTrace" />
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
<el-option label="自动识别" value="auto" />
<el-option label="顺丰速运" value="sf" />
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
</div>
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
<span class="text-gray-800">状态{{ logisticsTracePayload.state_text }}</span>
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
</div>
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
</div>
</el-card>
<!-- 操作日志 -->
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
</div>
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无操作日志" :image-size="64" />
</el-card>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, watch, ref, onMounted } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists } from '@/api/tcm'
import {
prescriptionOrderLists,
prescriptionOrderDetail,
prescriptionOrderLogs,
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'
const userStore = useUserStore()
const props = defineProps<{
diagnosisId: number
@@ -83,11 +379,187 @@ 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)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const detailUnlinkedPayOrders = ref<any[]>([])
const detailPrescription = computed(() => {
const p = detailData.value?.prescription
return p && typeof p === 'object' ? p : null
})
const detailLinkedPayOrders = computed(() => {
const arr = detailData.value?.linked_pay_orders
return Array.isArray(arr) ? arr : []
})
const detailAgencyToCollect = computed(() => {
const d = detailData.value
if (!d) return 0
const total = Number(d.amount) || 0
const paid = Number(d.linked_pay_paid_total) || 0
return Math.round((total - paid) * 100) / 100
})
const detailRecipientRxPhoneMismatch = computed(() => {
const rx = normalizeBizPhone(detailPrescription.value?.phone)
const biz = normalizeBizPhone(detailData.value?.recipient_phone)
if (!rx || !biz) return false
return rx !== biz
})
const detailFullAddress = computed(() => {
const d = detailData.value
if (!d) return '—'
const parts = []
if (d.shipping_province) parts.push(d.shipping_province)
if (d.shipping_city) parts.push(d.shipping_city)
if (d.shipping_district) parts.push(d.shipping_district)
if (d.shipping_address) parts.push(d.shipping_address)
return parts.length > 0 ? parts.join(' ') : '—'
})
const detailOrderCreatorMetaLine = computed(() => {
const d = detailData.value
if (!d) return ''
const account = String(d.creator_account ?? '').trim()
const mobile = String(d.creator_mobile ?? '').trim()
const parts: string[] = []
if (account) parts.push(`登录账号:${account}`)
if (mobile) parts.push(`手机:${mobile}`)
return parts.join(' · ')
})
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
const raw = String(detailData.value?.order_creator_dept_path ?? detailData.value?.diagnosis_creator_dept_path ?? '').trim()
if (!raw) return [] as string[][]
return raw.split('').map((s) => s.trim()).filter(Boolean)
.map((p) => p.includes(' / ') ? p.split(' / ').map((x) => x.trim()).filter(Boolean) : p.split('/').map((x) => x.trim()).filter(Boolean))
.filter((segs) => segs.length > 0)
})
//
const detailLogisticsExpress = ref<string>('auto')
const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
const logisticsTracePhoneTail = ref('')
const logisticsTraceList = computed(() => {
const p = logisticsTracePayload.value
if (!p || !Array.isArray(p.traces)) return []
return p.traces as Array<{ time: string; context: string }>
})
async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id)
if (!id) return
logisticsTraceLoading.value = true
try {
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id,
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>
} catch {
logisticsTracePayload.value = null
} finally {
logisticsTraceLoading.value = false
}
}
//
async function openDetail(id: number) {
detailData.value = null
detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null
detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = ''
detailLogs.value = []
detailVisible.value = true
detailLoading.value = true
try {
const res: any = await prescriptionOrderDetail({ id })
const d = res?.data ?? res ?? null
detailData.value = d
if (d) {
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
if (String(d.tracking_number || '').trim()) fetchLogisticsTrace()
fetchLogs(id)
if (d.diagnosis_id) {
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
}
}
} catch (e: any) {
feedback.msgError(e?.message || '加载详情失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
try {
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
const lists = res?.lists ?? res?.data?.lists ?? []
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
} catch { detailUnlinkedPayOrders.value = [] }
}
function canViewPrescriptionOrderLogs() {
const p = userStore.perms || []
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
}
async function fetchLogs(id: number) {
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
try {
const res: any = await prescriptionOrderLogs({ id })
detailLogs.value = res?.data ?? res ?? []
} catch { detailLogs.value = [] }
}
//
function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
}
const formatAmount = (value: unknown) => {
const n = Number(value)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
function formatMoney(v: unknown) {
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
const formatTime = (value: unknown) => {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
@@ -98,21 +570,86 @@ const formatTime = (value: unknown) => {
return String(value)
}
const fulfillmentText = (s: number | undefined) => {
const m: Record<number, string> = {
1: '待双审通过',
2: '待发货',
3: '已完成',
4: '已取消',
5: '已发货',
6: '已签收',
7: '进行中',
8: '暂不制药',
9: '拒收',
10: '退款',
11: '保留药方',
12: '制药缓发'
function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
function feeTypeText(t: number | undefined) {
const m: Record<number, string> = { 1: '药费', 2: '诊费', 3: '药费+诊费', 4: '其他' }
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 '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
function orderStatusText(s: number | undefined) {
const m: Record<number, string> = { 0: '待支付', 1: '待支付', 2: '已支付', 3: '已取消' }
return m[Number(s)] ?? '—'
}
const fulfillmentText = (s: number | undefined) => {
const m: Record<number, string> = { 1: '待双审通过', 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货', 6: '已签收', 7: '进行中', 8: '暂不制药', 9: '拒收', 10: '退款', 11: '保留药方', 12: '制药缓发' }
return m[Number(s)] ?? '—'
}
@@ -126,14 +663,22 @@ const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'dan
return 'info'
}
function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建', edit: '编辑', audit_rx_approve: '处方审核', audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核', audit_pay_reject: '支付审核', fill_tracking: '填快递单',
ship: '确认发货', withdraw: '撤销', link_pay_order: '关联支付单',
revoke_rx_audit: '撤回处方审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额', complete: '完成订单'
}
return m[act] || act
}
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
if (!patientIdAvailable.value) {
pager.lists = []
pager.count = 0
return
}
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return }
buildParams()
resetPage()
},
@@ -147,8 +692,25 @@ defineExpose({ refresh: () => getLists() })
.patient-order-list {
padding: 4px 0;
}
.po-empty-tip {
padding: 24px 0;
}
.audit-stamp {
position: absolute;
top: 12px;
right: 16px;
transform: rotate(15deg);
opacity: 0.15;
pointer-events: none;
z-index: 1;
}
.stamp-inner {
border: 3px solid currentColor;
border-radius: 8px;
padding: 4px 12px;
font-size: 18px;
font-weight: bold;
}
.stamp-pass { color: #22c55e; }
.stamp-reject { color: #ef4444; }
</style>
@@ -0,0 +1,152 @@
<template>
<div v-if="normalized.length" class="recording-list">
<div class="recording-item">
<template v-if="preferred">
<RecordingVideoPlayer
v-if="shouldInlineRecording(preferred)"
:key="`${recordId}-${preferred}`"
:src="preferred"
/>
<el-link v-else :href="resolveRecordingLink(preferred)" target="_blank" type="primary">
打开回放
</el-link>
</template>
<div v-if="alternates.length" class="recording-alternates">
<span class="recording-alternates__label">备用地址</span>
<div class="recording-alternates__links">
<el-link
v-for="(url, linkIndex) in alternates"
:key="`${recordId}-alt-${linkIndex}-${url.slice(-32)}`"
:href="resolveRecordingLink(url)"
target="_blank"
type="primary"
class="recording-alternates__link"
>
{{ alternateLinkLabel(url, linkIndex) }}
</el-link>
</div>
</div>
</div>
</div>
<span v-else class="text-gray-400">暂无</span>
</template>
<script setup lang="ts">
import RecordingVideoPlayer from './RecordingVideoPlayer.vue'
import useAppStore from '@/stores/modules/app'
import { computed } from 'vue'
const props = defineProps<{
recordId: number
/** getCallRecords 返回的 recording_urls_list(可多来源:COS/VOD、m3u8/mp4 */
urls?: string[]
}>()
const appStore = useAppStore()
/** 去重、trim */
const normalized = computed(() => {
const raw = props.urls || []
const seen = new Set<string>()
const out: string[] = []
for (const u of raw) {
const s = String(u ?? '').trim()
if (!s || seen.has(s)) continue
seen.add(s)
out.push(s)
}
return out
})
/** 优选一条内嵌:mp4 > 点播 m3u8 > COS m3u8 > 其它 m3u8 > 首个 */
const preferred = computed((): string | null => {
const list = normalized.value
if (!list.length) return null
const mp4 = list.find((u) => /\.mp4(\?|#|$)/i.test(u))
if (mp4) return mp4
const vodHls = list.find(
(u) => /\.m3u8(\?|#|$)/i.test(u) && /vod-qcloud\.com/i.test(u)
)
if (vodHls) return vodHls
const cosHls = list.find(
(u) =>
/\.m3u8(\?|#|$)/i.test(u) && /\.cos\.[^/]+\.myqcloud\.com/i.test(u)
)
if (cosHls) return cosHls
const anyHls = list.find((u) => /\.m3u8(\?|#|$)/i.test(u))
if (anyHls) return anyHls
return list[0]
})
const alternates = computed(() => {
const list = normalized.value
const p = preferred.value
if (!p) return list.slice(1)
return list.filter((u) => u !== p)
})
function shouldInlineRecording(url: string) {
if (!url || typeof url !== 'string') return false
const u = url.trim()
if (/^https?:\/\//i.test(u)) {
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u)
}
return /\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(u) || u.startsWith('/uploads/')
}
function resolveRecordingLink(url: string) {
return appStore.getImageUrl(String(url || '').trim())
}
function alternateLinkLabel(url: string, index: number): string {
const u = url.trim()
if (/\.mp4(\?|#|$)/i.test(u)) return `MP4 ${index + 1}`
if (/vod-qcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `点播 ${index + 1}`
if (/\.cos\.[^/]+\.myqcloud\.com/i.test(u) && /\.m3u8/i.test(u)) return `COS HLS ${index + 1}`
if (/\.m3u8/i.test(u)) return `HLS ${index + 1}`
return `链接 ${index + 1}`
}
</script>
<style scoped lang="scss">
.recording-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.recording-item {
display: flex;
flex-direction: column;
gap: 6px;
transform: translateZ(0);
}
.recording-alternates {
margin-top: 4px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.recording-alternates__label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.recording-alternates__links {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.recording-alternates__link {
font-size: 12px;
}
</style>
@@ -0,0 +1,379 @@
<template>
<div ref="wrapRef" class="recording-playback">
<div class="recording-playback__shell">
<video
v-show="!failed"
ref="videoRef"
class="recording-video"
controls
playsinline
webkit-playsinline
:preload="progressivePreload"
@error="onVideoError"
/>
<div
v-if="!failed && showBusyOverlay"
class="recording-playback__overlay"
role="button"
tabindex="0"
@click="forceEnable"
@keydown.enter.prevent="forceEnable"
>
<template v-if="!loadEnabled">
<span class="recording-playback__overlay-title">预览待加载</span>
<span class="recording-playback__overlay-desc">滚动至此处将自动加载减轻并发卡顿也可点击立即加载</span>
</template>
<template v-else>
<el-icon class="recording-playback__spin"><Loading /></el-icon>
<span>正在加载</span>
</template>
</div>
</div>
<template v-if="failed">
<el-link :href="playbackUrl || '#'" target="_blank" type="primary">在新窗口打开</el-link>
<div class="recording-playback__hint">
内嵌播放失败编码不支持跨域或私有存储等时可尝试在新窗口打开
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { Loading } from '@element-plus/icons-vue'
import useAppStore from '@/stores/modules/app'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps<{
src: string
}>()
const appStore = useAppStore()
const wrapRef = ref<HTMLElement | null>(null)
const videoRef = ref<HTMLVideoElement | null>(null)
const failed = ref(false)
/** 已进入可视区域或用户点击,允许发起请求 */
const loadEnabled = ref(false)
/** 已开始拉流但未就绪 */
const loadingMedia = ref(false)
let hlsInstance: import('hls.js').default | null = null
let intersectionObserver: IntersectionObserver | null = null
/** 防止 tearDown / 上一次 attach 的异步事件误结束 loading */
let attachGeneration = 0
let loadingSlowTimer: ReturnType<typeof setTimeout> | null = null
function clearLoadingSlowTimer(): void {
if (loadingSlowTimer !== null) {
clearTimeout(loadingSlowTimer)
loadingSlowTimer = null
}
}
const playbackUrl = computed(() => {
const raw = String(props.src || '').trim()
if (!raw) return ''
return normalizeTencentRecordingUrl(appStore.getImageUrl(raw))
})
/** COS/VOD 直连 mp4:允许浏览器优先拉元数据;HLS 仍 none,交给 hls.js */
const progressivePreload = computed(() => {
if (!loadEnabled.value) return 'none'
const u = playbackUrl.value
return u && !isHlsUrl(u) ? 'metadata' : 'none'
})
const showBusyOverlay = computed(() => !loadEnabled.value || loadingMedia.value)
/** 腾讯云录制路径里常见未编码的 “+”,部分环境下会导致视频长时间拿不到元数据 */
function normalizeTencentRecordingUrl(url: string): string {
if (!url || !/^https?:\/\//i.test(url)) return url
try {
const u = new URL(url)
const h = u.hostname.toLowerCase()
if (
h.includes('myqcloud.com') ||
h.includes('vod-qcloud.com') ||
h.includes('vod-qcloud')
) {
if (u.pathname.includes('+')) {
u.pathname = u.pathname.replace(/\+/g, '%2B')
}
return u.href
}
} catch {
/* ignore */
}
return url
}
function isHlsUrl(url: string): boolean {
return /\.m3u8(\?|#|$)/i.test(url)
}
function onVideoError(): void {
loadingMedia.value = false
clearLoadingSlowTimer()
const el = videoRef.value
const code = el?.error?.code
if (code === MediaError.MEDIA_ERR_ABORTED || code === 1) {
return
}
failed.value = true
}
function tearDown(): void {
if (hlsInstance) {
hlsInstance.destroy()
hlsInstance = null
}
const el = videoRef.value
if (el) {
el.pause()
el.removeAttribute('src')
el.load()
}
}
function bindProgressiveLoadingEnd(el: HTMLVideoElement, gen: number): void {
let settled = false
const done = () => {
if (settled || gen !== attachGeneration) return
settled = true
clearLoadingSlowTimer()
loadingMedia.value = false
}
el.addEventListener('loadedmetadata', () => done('loadedmetadata'), { once: true })
el.addEventListener('loadeddata', () => done('loadeddata'), { once: true })
el.addEventListener('canplay', () => done('canplay'), { once: true })
el.addEventListener('playing', () => done('playing'), { once: true })
el.addEventListener(
'progress',
() => {
try {
if (el.buffered.length > 0 && el.buffered.end(0) > 0) {
done('progress-buffered')
}
} catch {
/* ignore */
}
},
{ passive: true }
)
}
async function attachSource(): Promise<void> {
if (!loadEnabled.value) return
await nextTick()
const el = videoRef.value
const url = playbackUrl.value
if (!el) return
if (!url) {
loadingMedia.value = false
clearLoadingSlowTimer()
return
}
const gen = ++attachGeneration
loadingMedia.value = true
clearLoadingSlowTimer()
tearDown()
failed.value = false
if (isHlsUrl(url)) {
try {
const mod = await import('hls.js')
const Hls = mod.default
if (Hls.isSupported()) {
hlsInstance = new Hls({
enableWorker: true,
lowLatencyMode: false,
//
maxBufferLength: 20,
maxMaxBufferLength: 120
})
hlsInstance.on(mod.Events.MANIFEST_PARSED, () => {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
})
hlsInstance.on(mod.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
tearDown()
}
})
hlsInstance.loadSource(url)
hlsInstance.attachMedia(el)
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 45000)
return
}
if (el.canPlayType('application/vnd.apple.mpegurl')) {
bindProgressiveLoadingEnd(el, gen)
el.src = url
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 45000)
return
}
} catch {
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
return
}
if (gen !== attachGeneration) return
loadingMedia.value = false
clearLoadingSlowTimer()
failed.value = true
return
}
bindProgressiveLoadingEnd(el, gen)
el.src = url
// moov mp4 loadedmetadata
loadingSlowTimer = setTimeout(() => {
loadingSlowTimer = null
if (gen === attachGeneration && loadingMedia.value) {
loadingMedia.value = false
}
}, 25000)
}
function setupLazyLoad(): void {
const root = wrapRef.value
if (!root || typeof IntersectionObserver === 'undefined') {
loadEnabled.value = true
void attachSource()
return
}
intersectionObserver = new IntersectionObserver(
(entries) => {
for (const en of entries) {
if (en.isIntersecting) {
intersectionObserver?.disconnect()
intersectionObserver = null
loadEnabled.value = true
void attachSource()
break
}
}
},
{ root: null, rootMargin: '240px 0px', threshold: 0 }
)
intersectionObserver.observe(root)
}
function forceEnable(): void {
intersectionObserver?.disconnect()
intersectionObserver = null
if (!loadEnabled.value) {
loadEnabled.value = true
}
void attachSource()
}
onMounted(() => {
void nextTick(() => {
setupLazyLoad()
})
})
watch(playbackUrl, () => {
if (!loadEnabled.value) return
void attachSource()
})
onUnmounted(() => {
intersectionObserver?.disconnect()
intersectionObserver = null
clearLoadingSlowTimer()
attachGeneration++
tearDown()
})
</script>
<style scoped lang="scss">
.recording-playback {
width: 100%;
min-height: 1px;
}
.recording-playback__shell {
position: relative;
width: 100%;
}
.recording-video {
display: block;
width: 100%;
max-width: 100%;
max-height: 180px;
height: auto;
aspect-ratio: 16 / 9;
object-fit: contain;
border-radius: 4px;
background: #000;
transform: translateZ(0);
}
.recording-playback__overlay {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 12px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 12px;
line-height: 1.45;
text-align: center;
cursor: pointer;
user-select: none;
}
.recording-playback__overlay-title {
font-weight: 600;
}
.recording-playback__overlay-desc {
opacity: 0.92;
font-size: 11px;
}
.recording-playback__spin {
font-size: 24px;
animation: recording-spin 0.9s linear infinite;
}
@keyframes recording-spin {
to {
transform: rotate(360deg);
}
}
.recording-playback__hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
</style>
+35 -16
View File
@@ -151,12 +151,14 @@
stripe
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="ID" min-width="50">
<el-table-column label="ID" min-width="70">
<template #default="{ row }">
<div class="patient-cell">
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.id }}</div>
<div class="patient-name">
{{ row.id }}
<el-tag v-if="row.assign_read_at === null && row.assistant_id > 0" type="danger" size="small" effect="dark" class="ml-1">NEW</el-tag>
</div>
</div>
</div>
</template>
@@ -255,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)">
@@ -795,6 +797,32 @@ function buildTcmDiagnosisListRequestPayload(req: Record<string, unknown>): Reco
}
}
/**
* 待分配订单月份未选月份时未进入 Tab 前默认当月 handlePendingAssignTabClick 一致
* Tab 内清空月份表示不限月份
*/
function resolvePendingAssignOrderMonthForRequest(): string {
const trimmed = String(formData.pending_assign_order_month ?? '').trim()
if (trimmed !== '') return trimmed
if (formData.pending_assign === '1') return ''
return dayjs().format('YYYY-MM')
}
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
function buildPendingAssignCountPayload(): Record<string, unknown> {
return buildTcmDiagnosisListRequestPayload({
...formData,
page_no: 1,
page_size: 1,
pending_assign: 1,
appointment_date: '',
has_appointment: '',
pending_booking: '',
completed_appointment: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
} as Record<string, unknown>) as Record<string, unknown>
}
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
tcmDiagnosisLists(buildTcmDiagnosisListRequestPayload(req) as any)
@@ -923,16 +951,7 @@ const fetchDateCounts = async () => {
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists(
buildTcmDiagnosisListRequestPayload({
pending_assign: 1,
page_no: 1,
page_size: 1,
pending_assign_order_month: formData.pending_assign_order_month,
pending_assign_keyword: formData.pending_assign_keyword,
keyword: formData.keyword
}) as any
)
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -960,7 +979,7 @@ function scheduleDeferredDateCounts() {
}
}
const SILENT_LIST_POLL_MS = 10_000
const SILENT_LIST_POLL_MS = 20_000
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
@@ -1352,7 +1371,7 @@ const handleSingleAssign = (row: any) => {
currentRow.value = row
batchAssignIds.value = []
//
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignDialogVisible.value = true
}
@@ -130,6 +130,7 @@
</div>
<div class="dh5-card-sub">
#{{ row.id }} · {{ row.diagnosis_date_text || formatDateTime(row.create_time) }}
<span v-if="row.assign_read_at === null && row.assistant_id > 0" class="dh5-tag-new">NEW</span>
</div>
</div>
<div class="dh5-card-tags">
@@ -1672,6 +1673,18 @@ $dh5-card-bg: #ffffff;
flex-wrap: wrap;
}
.dh5-tag-new {
display: inline-block;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
margin-left: 4px;
vertical-align: middle;
}
.dh5-meta-gender,
.dh5-meta-age {
font-size: 12px;
+146 -14
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>
@@ -40,23 +40,110 @@
<PatientCaseCard :apt="apt" :diag="diag" />
<!-- 日常记录只读 -->
<div class="card tracking-card">
<div v-if="hasDailyRecordPermission" class="card tracking-card">
<div class="card-title">日常记录</div>
<DailyMatrix
:diagnosis-id="Number(diagnosisIdNum)"
:patient-id="Number(diag?.patient_id ?? 0)"
:age="diag?.age ?? null"
:patient-name="diag?.patient_name || ''"
:read-only="true"
/>
<div class="card-body">
<DailyMatrix
:diagnosis-id="diagnosisNumericId"
:patient-id="patientNumericId"
:age="diag?.age ?? null"
:patient-name="diag?.patient_name || ''"
:read-only="true"
/>
</div>
</div>
<!-- 医生备注复用 reception NoteTimeline只读 -->
<div v-if="doctorNotes.length" class="card note-card">
<!-- 医生备注舌苔照片检查报告只读与编辑页医生备注同源 -->
<div class="card note-card">
<div class="card-title">医生备注 & 舌苔照片 & 检查报告</div>
<NoteTimeline :notes="doctorNotes" :diagnosis-id="diag?.id" />
<div class="card-body card-body--matrix-align">
<el-empty v-if="diagnosisNumericId <= 0" description="诊单数据不完整" />
<el-empty
v-else-if="!doctorNotesLocal.length"
description="暂无医生备注"
/>
<NoteTimeline
v-else
:notes="doctorNotesLocal"
:diagnosis-id="diagnosisNumericId"
:readonly="true"
/>
</div>
</div>
<!-- 业务订单 -->
<div
v-if="hasPermission(['tcm.diagnosis/patientOrders'])"
class="card section-card"
>
<div class="card-title">业务订单</div>
<div class="card-body card-body--matrix-align">
<patient-order-list
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
:patient-id="patientNumericId || 0"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 视频录制回放记录 -->
<div v-if="hasPermission(['tcm.diagnosis/huifang'])" class="card section-card">
<div class="card-title">视频录制回放记录</div>
<div class="card-body card-body--matrix-align">
<call-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
:read-only="true"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 聊天记录 -->
<div v-if="hasPermission(['tcm.diagnosis/chat'])" class="card section-card">
<div class="card-title">聊天记录</div>
<div class="card-body card-body--matrix-align">
<im-chat-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 指派医助记录 -->
<div
v-if="
hasPermission(['tcm.diagnosis/assign']) ||
hasPermission(['tcm.diagnosis/detail'])
"
class="card section-card"
>
<div class="card-title">指派医助记录</div>
<div class="card-body card-body--matrix-align">
<assign-log-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
<!-- 挂号记录 -->
<div
v-if="hasPermission(['doctor.appointment/lists'])"
class="card section-card"
>
<div class="card-title">挂号记录</div>
<div class="card-body card-body--matrix-align">
<appointment-record-panel
v-if="diagnosisNumericId > 0"
:diagnosis-id="diagnosisNumericId"
/>
<el-empty v-else description="诊单数据不完整" />
</div>
</div>
</template>
</div>
</template>
@@ -66,10 +153,17 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { diagnosisReadonlyDetail } from '@/api/tcm'
import { getDoctorNotes } from '@/api/patient'
import { formatGender } from '@/utils/diag-display'
import { hasPermission } from '@/utils/perm'
import PatientInfoCard from './components/PatientInfoCard.vue'
import PatientCaseCard from './components/PatientCaseCard.vue'
import DailyMatrix from './components/DailyMatrix.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import AssignLogPanel from './components/AssignLogPanel.vue'
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
import PatientOrderList from './components/PatientOrderList.vue'
import NoteTimeline from '@/views/patient/reception/components/NoteTimeline.vue'
const route = useRoute()
@@ -80,10 +174,19 @@ const diagnosisIdNum = computed(() => Number(route.query.id || 0))
const loading = ref(false)
const errorMsg = ref('')
const detail = ref<any>(null)
/** 医生备注:readonlyDetail 返回 + getDoctorNotes 对齐接诊台结构 */
const doctorNotesLocal = ref<any[]>([])
const apt = computed<any>(() => detail.value?.appointment || {})
const diag = computed<any>(() => detail.value?.diagnosis || {})
const doctorNotes = computed<any[]>(() => detail.value?.doctor_notes || [])
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const diagnosisNumericId = computed(() =>
Number(diag.value?.id ?? diagnosisIdNum.value ?? 0)
)
const patientNumericId = computed(() => Number(diag.value?.patient_id ?? 0))
const unservedDays = computed<number | null>(() => {
const v = detail.value?.unserved_days
return v === null || v === undefined ? null : Number(v)
@@ -103,11 +206,23 @@ const unservedDaysClass = (n: unknown) => {
return 'unserved-green'
}
const fetchDoctorNotes = async () => {
const id = diagnosisNumericId.value
if (!id) return
try {
const data = (await getDoctorNotes({ diagnosis_id: id })) || []
doctorNotesLocal.value = data
} catch {
/* 失败时保留 readonlyDetail 已下发的 doctor_notes,避免整块备注空白 */
}
}
const fetchDetail = async () => {
const id = diagnosisIdNum.value
if (!id || id <= 0) {
errorMsg.value = '诊单 id 缺失'
detail.value = null
doctorNotesLocal.value = []
return
}
loading.value = true
@@ -115,9 +230,12 @@ const fetchDetail = async () => {
try {
const res: any = await diagnosisReadonlyDetail({ id })
detail.value = res || null
doctorNotesLocal.value = Array.isArray(res?.doctor_notes) ? res.doctor_notes : []
await fetchDoctorNotes()
} catch (e: any) {
errorMsg.value = e?.message || ''
detail.value = null
doctorNotesLocal.value = []
} finally {
loading.value = false
}
@@ -276,4 +394,18 @@ onMounted(fetchDetail)
background: linear-gradient(180deg, #4f8cff 0%, #2563eb 100%);
}
}
.card-body {
min-width: 0;
}
/* 与 DailyMatrix 根节点 .daily-matrix { padding: 16px } 对齐,使下方区块与「日常记录 / 待办」主内容区左右留白一致 */
.card-body--matrix-align {
padding: 16px;
box-sizing: border-box;
}
.section-card .card-title {
margin-bottom: 0;
}
</style>
+6 -4
View File
@@ -97,12 +97,14 @@
stripe
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column label="ID" min-width="50">
<el-table-column label="ID" min-width="70">
<template #default="{ row }">
<div class="patient-cell">
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.id }}</div>
<div class="patient-name">
{{ row.id }}
<el-tag v-if="row.assign_read_at === null && row.assistant_id > 0" type="danger" size="small" effect="dark" class="ml-1">NEW</el-tag>
</div>
</div>
</div>
</template>
@@ -1088,7 +1090,7 @@ const handleSingleAssign = (row: any) => {
assignMode.value = 'single'
currentRow.value = row
//
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignForm.value.assistant_id = Number(row.assistant_id) > 0 ? Number(row.assistant_id) : null
assignDialogVisible.value = true
}
File diff suppressed because one or more lines are too long
+16 -2
View File
@@ -6,7 +6,7 @@ import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import { createStyleImportPlugin, ElementPlusResolve } from 'vite-plugin-style-import'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
@@ -36,13 +36,26 @@ function getElementPlusStylePaths() {
return stylePaths
}
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const rawBase = (env.VITE_APP_BASE_URL || '').trim().replace(/\/+$/, '')
const uploadsProxyTarget =
rawBase.startsWith('http://') || rawBase.startsWith('https://')
? rawBase
: 'http://127.0.0.1:8080'
return {
base: '/admin/',
server: {
host: '0.0.0.0',
hmr: true,
open: true,
proxy: {
// 开发环境:相对路径 /uploads/* 由 PHP 提供,需转发到后端否则 Vite 端口下 404(录制回放黑屏)
'/uploads': {
target: uploadsProxyTarget,
changeOrigin: true
},
'/api-proxy': {
target: 'https://admin.zhenyangtang.com.cn',
changeOrigin: true,
@@ -112,4 +125,5 @@ export default defineConfig({
__VUE_PROD_HYDRATION__: true,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
}
}
})
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+1
View File
@@ -0,0 +1 @@
open_basedir=/www/wwwroot/zyt/server/:/tmp/
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

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