Compare commits

...

10 Commits

Author SHA1 Message Date
lc bce796d893 feat: 首页按钮样式调整(小圆角/并排/高度对齐)
- 主按钮与自由/循环训练按钮圆角统一为 16rpx(直角→折中方案)
- 自由训练/循环训练两个次按钮改为并排一行,文字精简为「自由训练」「循环训练」
- 两个次按钮 size 由 md 改为 xl,与主按钮高度一致
- 新增 preview-buttons.html 对照预览
- 更新工作记忆
2026-08-03 17:04:05 +08:00
lc 6ff70971d8 docs: 工作日志补记本次提交与 tts 云函数部署待办 2026-08-03 16:35:33 +08:00
lc 3532d0111b feat: 循环训练模式 + 语音补全 / 屏幕常亮 / 卡片渲染修复
循环训练(circuit)
- 新增 utils/circuitTimer.js 状态机: 工作->休息->...->完成, stop() 返回有效撑总秒(不含休息)
- 首页新增「循环训练·分组练习」入口 + stepper 配置弹窗
- 配置本地持久化 circuit_config(每组时长/组数/休息/每周目标), 下次自动预填
- 记录以 planId:'circuit' + mode:'circuit' 落库, duration 语义不变 -> 排行榜/统计/每日计划进度零改动
- 记录页展示「循环 N组xMs·休Rs」

计时器顶部循环进度面板
- 组进度点阵按组数等分铺满(flex:1 1 0, 去掉 max-width 封顶), 当前组以增高而非加宽强调
- 大号「X/Y 组」+ 四态阶段徽章(撑住/休息中/准备开始/已暂停) + 累计秒数副行
- 状态灯用纯 CSS 圆点(SVG 图标颜色烤死在 data URI 里, CSS 改不动)
- 超过 15 组自动降级为线性进度条; 面板不设固定高度, 规避真机大字体档裁字

修复: 循环训练全程无语音
- 根因: circuit 的 onTick 走 _circuitCue(只有震动), 从未调用 _remind
- 接入整场进度播报, 基准为 sets x holdPerSet(而非每组), 避免 4 组播 4 次「已完成一半」
- 新增 restStart/nextSet/lastSet 三条不含数字的词条, 客户端与 tts 云函数词表同步
- 修复必然撞车的时序: 偶数组时 halfway 与休息切换同一 tick, 加 2.6s 优先窗口让位
- 防吵闸门: 每组 <10s 不播过场语音, 休息 <5s 不播; 预载按模式取词条

训练期间屏幕常亮
- wx.setKeepScreenOn: onStart 开 / onUnload 关 / onShow 在 isRunning 时重新武装
- 切后台再回来该标志会被系统清掉, 故必须重新武装, 否则后半程丢语音与震动
- 低基础库与 Android 省电模式静默降级, 不弹 toast

修复: 首页打卡卡片左侧两个竖条
- 根因: ui-card 宿主节点无 display 声明为 inline, 页面侧加的 border 被打断成两个零宽行盒碎片
- ui-card 加 :host{display:block}, margin-bottom 从内部 view 上提到宿主
- 高亮由 border 改为 box-shadow spread 描边(不占布局, 无跳动) + 补 border-radius
- 连带修复失效的交错入场: nth-child 跨组件边界恒匹配 1, 改为 index 属性驱动内联 animation-delay, 14 处调用点补序号

注意: tts 云函数需重新部署, 否则新增的 3 条词条返回 Unknown prompt key(静默降级不报错)
2026-08-03 16:34:22 +08:00
lc 76cd2b6c0f fix: 三个 P2 小修(榜单离线兜底/同天连胜文案/热力图日期解析)
- leaderboard.js _applyLocal 离线兜底补 subText(N次),与云端路径对齐
- timer.js _detectCelebrationLevel 增加 lastIsToday 判断,修复同天二次训练连胜文案虚高 1 天
- calendar-heatmap.js 用 util.dateOnly 归一化后再取'日',修复脆弱的 split 解析
2026-07-31 10:23:44 +08:00
lc deaff72763 refactor(index): 去掉首页底部 toolbar 上方的小提示卡片
- 删除首页随机姿势提示卡片(homeTip):wxml 移除 tip-card 整块、wxss 清理 .tip-card/.tip-icon/.tip-text、js 移除 homeTip 逻辑
- config.postureTips 仍被训练页复用,保留不动
2026-07-31 09:42:11 +08:00
lc 1589d6c387 feat(leaderboard): 新增耐力榜(全局单次最久 top10) + 领奖台日期两行 + 顶部哲理文案
- 云函数支持 endurance 周期:遍历全部 records 取 max(duration) 及 bestDate
- 客户端新增耐力榜 tab(maxRank=10),未上榜时在底部 my-bar 显示最佳成绩+产生时间
- 领奖台底座(耐力榜)单次日期拆成「日期+时间」两行,统一三阶底座高度修复第3名贴顶
- 领奖台顶部新增哲理文案,配置进 config.js(leaderboardPodiumSlogan),斜体金句卡片样式
2026-07-31 09:30:30 +08:00
lc fca3e32d04 优化记录页趋势分析展示 2026-07-29 14:46:38 +08:00
lc 19b0c4d4c8 修复趋势图表加载与比例展示 2026-07-29 14:18:45 +08:00
lc 1372ec19b8 新增勋章图标替换设计说明 2026-07-29 14:17:54 +08:00
lc 903951aee0 优化勋章分段进度展示 2026-07-29 11:45:20 +08:00
38 changed files with 2354 additions and 225 deletions
+11
View File
@@ -0,0 +1,11 @@
# 2026-07-30 工作日志
## 朋友提供的勋章体系 PRD 评审(纯分析,未改代码)
- 方案为三轴:毅力试炼(连续 streak 天数 3/7/15/21/30/66/100)、力量殿堂(累计时长 30min/3h/10h/50h)、极限时刻(单次时长 1/2/5min)。
- 结论:方向合理、数据齐(streak.count / totalDuration / maxDuration 现有 storage 均已有)、不浪费现有工作(现有 trainingDayBadges 可降级为"总天数纪念线")。
- 三处必须修:
- P0 护盾机制只提名字没设计——streak 线生死线,漏一天归零会劝退,需补"消耗品断签保护"规格。
- P0 文案/视觉全用 emoji,与用户既定"不想用 emoji"冲突,须换矢量图标(belt/shield/stopwatch 缺,需新增)。
- P1 需拍板"首页 C 位放 streak 还是累计天数"(用户早前确认过累计天数可中断,PRD 把 streak 提为核心线,不矛盾但需决策)。
- 落地注意:14 枚超单进度条容量→需勋章墙/分 Tab;config 阈值写秒;勋章同获不退(streak 断了不回收)。
- 用户明确要求:本次只分析、不改代码。
+51
View File
@@ -0,0 +1,51 @@
# 2026-07-31 工作日志
## 排行榜新增「耐力榜」(endurance)
- 需求:全局单次最久 top10,显示该次日期;本人未上榜则在底部 my-bar 显示最佳成绩+产生时间。
- 云函数 `cloudfunctions/leaderboard/index.js`
- `_buildFromScan` 新增 `endurance` 分支:遍历每人全部 records 取 `max(duration)`,记下 `bestDate`(该条 r.date)`ranked`/`myEntry` 映射均透传 `bestDate`
- `exports.main` 放行 `endurance``_rebuildSnapshots` 循环加入 `endurance`(随定时器每 4 分钟重算,存 top500)。
- `bestDate``publicEntry``...publicData` 展开自动进快照/响应,data.js 无需改。
- 客户端 `pages/leaderboard/leaderboard.js`
- `periods``{key:'endurance',label:'耐力',maxRank:10}`(日/月/年保持 config.leaderboardMaxRank=50)。
- `fetchRank` 按当前周期取 `maxRank`(不再写死)。
- `_applyResult` 计算 `subText`(耐力=格式化 bestDate 去秒,其余=`N次`);新增 `_formatBestDate` 辅助。
- `_applyLocal` 加耐力兜底分支(本地 records 取 max+日期);分享 map 加 `endurance:'耐力'`
- wxml 5 处 `{{x.sessions}}次``{{x.subText}}`(领奖台3/列表1/my-bar1)。
- wxss `.podium-base__label``nowrap`+省略号,防窄屏日期串换行撑破领奖台底座。
- 校验:`node --check` 云函数+客户端均通过;grep 确认无 `sessions}}次` 残留。
- **待办**:改云函数后须重新部署(定时器随部署注册),否则 endurance 周期不生效。改动尚未提交推送,等用户确认。
## 领奖台底座日期时间拆两行(耐力榜)
- 用户要求领奖台底座的「单次日期」拆成日期+时间两行。
- `leaderboard.js``_applyResult`/`_applyLocal` 给每条耐力榜数据补 `subDate`(YYYY-MM-DD)、`subTime`(HH:MM) 两字段(新增 `_splitBestDate` 从 bestDate 截断);`subText` 仍保留供列表/my-bar 单行使用。
- `leaderboard.wxml`:领奖台 3 处 `<text>` 改为 `<view>` 容器 + `wx:if activePeriod==='endurance'` 渲染两行 `podium-base__date`/`podium-base__time`,否则单行 `subText`
- `leaderboard.wxss``.podium-base__label` 由单行绝对定位 text 改为 flex column 居中 view(去掉 nowrap/ellipsis),新增 `.podium-base__date`(20rpx)/`.podium-base__time`(18rpx) 缩小字号以适配最矮的第3名底座(48rpx)。
- 校验:`node --check` 通过,grep 确认 subDate/subTime/_splitBestDate 已接入。
## 领奖台第3名贴顶修复 + 顶部文案
- 第3名底座仅 48rpx,两行日期时间文字溢出贴顶。统一抬高三阶底座高度:first 96 / second 64→80 / third 48→64(梯度 96/80/64)。
- `.podium-base__label` bottom 8→6rpx;耐力榜两行字号缩小:date 20→18rpx、time 18→16rpx。两级余量足以抗微信字体缩放放大。
- 领奖台顶部新增哲理文案(仅耐力榜显示,`wx:if activePeriod==='endurance'`):
「平板支撑不是一场和时间的恋爱。撑得越久,不一定越好;真正重要的,是每一秒都没有辜负身体。」
- 文案配置进 `config.js` 新增 `leaderboardPodiumSlogan``leaderboard.js` data 挂 `podiumSlogan: config.leaderboardPodiumSlogan`wxml 在 `<block wx:else>` 内 podium 前插入 `.podium-slogan` 块;wxss 新增 `.podium-slogan` 样式(居中、淡色、引用感卡片)。
- 校验:`node --check` 客户端 + config 均通过;grep 确认 podiumSlogan / leaderboardPodiumSlogan / podium-slogan 三处链路完整。
## 顶部文案配色与样式微调
- 首版 `.podium-slogan` 为灰底灰字,用户认为不好看 → 改为「极淡橙渐变底 + 主色描边 + 主色微光阴影 + 顶部主色大引号(`::before` `\201C`)点睛」金句卡片质感(用 `--primary-rgb`/`--text` 变量,深色模式自适应)。
- 用户再要求:去掉引号、文案改斜体。已删除 `.podium-slogan::before` 块;主块加 `font-style: italic`,并把 padding-top 从 26rpx 收到 22rpx 抵消去掉引号后的顶部留白。纯样式改动,无逻辑、无需重新部署云函数。
## 首页去掉 toolbar 上方小提示卡片
- 删除首页底部随机姿势提示卡片(homeTip)wxml 移除 tip-card 整块、wxss 清理 .tip-card/.tip-icon/.tip-text、js 移除 data.homeTip + 随机取 postureTips 逻辑 + setData 引用。
- `config.postureTips` 仍被训练页(timer)复用,保留不动。纯前端,编译即可生效,无需重新部署云函数。
- 提交:`deaff72`(含 config.js v3.02→v3.1 升版 + 顶部文案标点修正,均无害)。
## 三个 P2 小修(体检发现)
1. **离线兜底缺"N次"**`pages/leaderboard/leaderboard.js` `_applyLocal` 非 endurance 分支 entry 漏 `subText`,补 `subText: \`${sessions}次\``,与云端 `_applyResult` 对齐。断网/未部署时底部成绩栏不再留白。
2. **同天二次训练文案虚高 1 天**`pages/timer/timer.js` `_detectCelebrationLevel` 未判断 streak.lastDate 是否已为今天。updateStreak 对同天再次训练不递增,故 `nextStreak``lastIsToday` 判断:是则取 `streak.count`,否则取 `count+1`。只错文案、存储连胜数本就对。用 `storage.getToday().substring(0,10)` 比 lastDatetimer.js 未引入 util,故未用 util.dateOnly)。
3. **热力图日期解析脆弱**`components/calendar-heatmap/calendar-heatmap.js` line 64 原 `parseInt(r.date.split('-')[2])` 靠 parseInt 侥幸解析 `DD HH:MM:SS`。改为 `parseInt(util.dateOnly(r.date).split('-')[2], 10)`,与全局日期规范统一(该组件已引入 util)。
- 校验:`node --check` 三文件均通过;grep 确认三处改动已落实。纯前端,无需重新部署云函数。尚未提交推送,等用户确认。
## 配置澄清
- `config.js``leaderboardMaxRank: 50` 是**客户端显示行数上限**(日/月/年榜 50、耐力榜 10)。
- 云函数 `cloudfunctions/leaderboard/index.js:11` `SNAPSHOT_TOP = 500` 是**快照每周期存储人数上限**(写死在云函数,不读 config)。底部「我的成绩」栏靠 findMyEntry 在存下的完整 500 里找本人,故排 51~500 也能在底部显示;仅走缓存快照时受 500 限制(force 实时重算路径在完整全量榜找人,不受 500 限)。两数字各管一摊。
+212
View File
@@ -0,0 +1,212 @@
# 2026-08-03 工作日志
## 规划:分阶段训练(循环训练 circuit)功能
用户想在平板支撑小程序加入"分阶段训练":自定义 每组时长 / 每次组数 / 每周训练次数。
分析现有代码后结论:
- 现有 `utils/plan.js` 是"单组持续撑"模型(hold 模式),计划按天线性加秒;设置页已能自定义公式参数(totalDays/startTarget/increment/cycleDays),但无"组数/休息/周频次"概念。
- 用户要的是"循环训练(circuit)"范式,当前数据模型与计时器都表达不了,但云同步/计划选择/进度条/记录页骨架可复用。
### 已确认的设计决策(与用户拍板)
1. **双模式并存**:保留现有3个预设计划和老用户进度(mode:'hold'),新增 circuit 类型(mode:'circuit')。不破坏榜单/已有数据。
2. **弹性训练**:任意一天都能练;sessionsPerWeek 只用于显示「本周 X/N 次」(自然周周一重置),不卡进度(`getPlanDay` 已按训练日推进)。
3. **扁平 routine 先上**:整个计划每次 routine 相同(如 4组×30秒),靠"完成次数"和"每周次数"做进度;逐周进阶作为后续增强,MVP 不做。
### 约定细节
- 默认组间休息 15 秒(编辑器可改)。
- 循环预设示例:"新手 4组×20秒·休息15秒·3次/周·4周"。
- `duration` 始终存"有效撑总秒数 = sets×holdPerSet"(不含休息),保证排行榜/里程碑零改动。
### 计划改动清单(原方案,较完整,本次未走此路线)
- utils/plan.js:加 mode + circuit 目标解析 + 1 个循环预设
- utils/storage.jssaveRecord 支持 sets/holdPerSet/restPerSet/mode
- pages/timer + 新 utils/circuitTimer.jscircuit 状态机与 UI
- pages/index:目标显示"4组×30秒·休息15秒" + 本周进度
- pages/settings:编辑器加"循环模式"输入项
- pages/records:展示"4组×30秒"
- 榜单/云函数:不动
## 方案简化(用户两次追问"还有没有再简单一点的方案")
原方案把 circuit 做成完整"计划管理系统"(mode/预设/进度进阶/周频次追踪/设置页编辑器) 过度设计。
最终定位为「自由训练」的循环形态,而非新计划体系:
- 首页加「循环训练」入口:输入 每组时长/组数/休息秒数 → 跳 timer?circuit=1&hold=&sets=&rest=
- timer 内用新 utils/circuitTimer.js(类 Timer 接口: start/stop/pause/resume + onTick/onPhaseChange) 跑 工作/休息 状态机;UI 显示「第X/Y组」+ 阶段 + 组内剩余
- 首页主「开始训练」仍走原 hold 计划,circuit 不替换每日目标 → 完全不动 index 的计划逻辑
- storage.saveRecord 加可选 sets/holdPerSet/restPerSet/modeduration 仍=有效撑总秒(sets×hold),榜单零改
- records 页:circuit 记录显示「4组×30秒」
- **砍掉**plan.js mode、circuit 预设、设置页编辑器、plan-day 进阶、周频次追踪(周频次可仅作为 picker 里可选提示,先不做追踪)
- 唯一"真新逻辑"= circuitTimer 状态机;其余皆小 UI。风险与原方案同级(无新权限/域名)。
用户最终确认:**先做简化版,但循环训练设置要持久化**(下次打开不重填)。
## 已实现:简化版循环训练 + 配置持久化(2026-08-03 落地)
### 改动文件
- **utils/storage.js**:新增 `CIRCUIT_CONFIG_KEY` / `getCircuitConfig` / `saveCircuitConfig`(本地持久化,含默认值 `{holdPerSet:30, sets:4, restPerSet:15, sessionsPerWeek:3}` + 范围校验,**不进云同步**——纯设备偏好)。
- **utils/circuitTimer.js**(新):`CircuitTimer` 状态机 `idle→working→resting→…→done`,类 `Timer` 接口(`start/stop/pause/resume` + `onTick/onPhaseChange/onComplete`),`stop()` 返回 `workTotal`(有效撑总秒,不含休息),复用 `Date.now`+`onAppShow` 前台补偿。
- **pages/timer/timer.{js,wxml,wxss}**`_init` 检测 `options.circuit``_initCircuit` 建 FSM + circuit 字段;新增 `_circuitCue/_circuitPhase/_onCircuitComplete/_vibrateOnce/_int`;阶段切换走**震动 + phaseTip 文本**TTS 无"第N组/休息"词,刻意不堆语音),完成复用 `complete` 语音;`_saveAndShowCompletion``record.mode='circuit'` + `sets/holdPerSet/restPerSet/sessionsPerWeek`**`planId``'circuit'`**(避免 `getPlanDay` 把循环记录算进每日计划进度);`onTrainAgain` 在 circuit 模式重建 FSMcompletion overtime 在 circuit 隐藏(每阶段是固定目标,无"超长发挥"概念)。
- **pages/index/index.{js,wxml,wxss}**:加「循环训练 · 分组练习」ghost 按钮(`repeatFill` 图标);底部配置弹窗(stepper 调 每组时长/组数/休息/每周目标),确认后 `saveCircuitConfig` + 跳 `timer?circuit=1&hold=&sets=&rest=&sessions=`
- **utils/icons.js**:加 `repeat`/`repeatFill`Material repeat 路径,build 自动给 gray + primary 两变体)。
- **pages/records/records.{js,wxml}**circuit 记录显示「循环 N组×Ms(·休Rs)」,基于 `r.mode`
### 关键不变量(续做时牢记)
- `duration` 始终 = 有效撑总秒(组数×每组秒,不含休息)→ 排行榜/里程碑/统计零改动。
- 循环记录 `planId:'circuit'`,不污染每日计划 `getPlanDay`;连胜 `updateStreak` 仍计入(用户确实练了)。
- 进度环在 circuit 模式轨道恒中性灰(否则每阶段都按"累计≥阶段长"误判变绿)。
### 验证
6 个 JS 全过 `node --check`。未动 plan.js / 云函数 / 榜单 / 云同步。
### 未做(可选后续)
- 周频次硬追踪(仅存 sessionsPerWeek 作显示,未做"本周 X/N 次"计数)
- 逐周自动进阶
- 设置页统一管理入口
## 修复+重做:循环训练页顶部进度面板(2026-08-03 二次迭代)
用户反馈"顶部只有『1/3组,撑住』太简单难看"。**根因:上一轮只写了 `.circuit-bar` 的 WXML 结构,`timer.wxss` 里一行样式都没写**(grep 确认零匹配),所以渲染出来就是两段裸文字。
### 新设计(信息层次分工)
- **顶部面板管宏观「第几组」**,中间进度环管微观「本组还剩几秒」——不再重复展示同一信息。
- 结构:组进度胶囊点阵 → 大数字 `2/4 组` + 阶段徽章 → 副行「已撑 42 秒 · 目标 120 秒」。
- 点阵三态:`is-done`(实心主色) / `is-active`(加宽+主色渐变+发光呼吸) / `is-next`(休息时高亮下一组,success 绿呼吸)。
- 徽章四态 BEM`--work`(主色渐变实心+白灯白字) / `--rest`(success 绿淡底) / `--idle` / `--pause`(中性灰)。休息态整张卡边框也转绿,视觉降温。
- 待机态显示总组数 `4 组` + "准备开始" + "每组30秒·休息15秒";运行态显示 `2/4 组` + 累计秒数。
### 实现要点(踩坑记录)
- **状态指示灯用纯 CSS 圆点,不用 icon**:项目里 SVG 图标颜色烤死在 data URI,CSS 改不动;而这个灯需要随状态换色(白/绿/灰),纯 CSS 是唯一干净解。
- **点阵用 `flex:1` + `max-width`**,任意组数都均分不溢出;`is-active``flex:2` 加宽突出。组数 >15 (`DOTS_MAX`) 退化为线性进度条 `.circuit-track`
- **面板不设固定 height**,靠 padding 撑开——真机大字体档会放大文字但不放大 rpx 盒子,固定高度必裁字(项目老坑)。
- **`.circuit-bar` 必须 `position:relative; z-index:1`**`.breathe-ring`(z-index:0) 在 DOM 里排在面板之后,不抬层橙色光晕会晕染到白卡片上。
- 暂停时 `.is-paused` 冻结所有 dot 呼吸动画——静止本身就是"已暂停"信号。
- dots 三态在 `onTick` 里算(与 `isResting` 同批 setData,不会状态不同步):resting 时 CircuitTimer 的 `setIndex` 仍指向"刚做完那组",故 `dotsDone=setIndex``dotsActive=0``dotsNext=setIndex+1`
- `_initCircuit` 新增 `circuitDots`(仅 [1..sets] 供 wx:for) / `circuitShowDots` / `circuitTotalWork`(sets×hold)。
- hint-section 的 circuit idle 文案改为「循环训练 · 共需撑 N 秒」,避免与面板副文案重复。
改动文件:`pages/timer/timer.{js,wxml,wxss}``node --check` 通过,无旧类名残留。
## 微调:组进度点阵改为按组数严格等分铺满(2026-08-03 三次迭代)
用户反馈"点阵只占顶部一部分,应该按组数平均分配到整行"。
- **根因**`.circuit-dot``max-width: 64rpx` 给每段宽度封顶,`.circuit-dots` 默认 `justify-content:flex-start` → 组数少时(3-4 组)点阵只占左边一截。此外 `is-active``flex:2` 加宽,本身就破坏了"按组数等分"。
- **修法**`.circuit-dot``flex:1 1 0; min-width:0`**删掉 `max-width`**`.circuit-dots``width:100%`。当前组/下一组不再靠"加宽"强调,改为 **增高**12rpx → 18rpx,圆角同步 6→9rpx)+ 渐变/发光/呼吸,宽度恒等分。
- `.circuit-dots``min-height:18rpx` 锁行高,避免 idle(无 active,行高 12rpx)→ running(有 active18rpx)时整块跳 6rpx。
- transition 从 `flex` 改为 `height`(强调维度变了)。
- 仅改 `pages/timer/timer.wxss`,无 JS/WXML 改动。
## 修复:循环训练缺少过程语音(2026-08-03 四次迭代)
用户反馈"循环训练缺少正常训练时候的那些语音提示"。**根因有两处,都是设计遗漏**:
1. **`_circuitCue` 从未调用 `_remind`**:单组模式 `Timer.onTick` 里有 `this._remind(tick)`(负责 halfway/last30/last10 三条语音),circuit 的 `onTick` 只调 `_circuitCue`,里面仅有"最后一组末 5 秒震动"。
2. **`_circuitPhase` 当初刻意不放语音**(注释写明"TTS 无第N组/休息词条")。
结果整场只剩 2 声:`onStart``start` + `_onCircuitComplete``complete`
### 修法
- **新增 3 个 TTS 词条**`utils/voice.js``cloudfunctions/tts/index.js` 的 PROMPTS **必须同步**):`restStart`(休息一下,调整呼吸) / `nextSet`(下一组,准备开始) / `lastSet`(最后一组,全力冲刺!)。**刻意不含组号数字** → 固定 3 条覆盖任意组数,无需按 N 合成 N 份音频。
- **`_circuitCue` 加整场进度播报**:基准是 `total = sets × holdPerSet`(**整场有效秒,不是每组**)——否则 4 组会播 4 次"已完成一半啦",语义全错。判据用 `done >= total/2``left <= 30 / <= 10``<=` 而非 `===`,容忍后台回前台跳秒)。只在 `phase==='working'` 求值(休息时 workTotal 冻结,天然不会重复触发)。门槛沿用 `_remind`halfway 需 total>=10、last30 需 total>60、last10 需 total>20。
- **`_circuitPhase` 加阶段语音 + `chatty` 闸门**`holdPerSet >= 10` 才播 `nextSet`/`restStart`(一句话约 2 秒,短组会变语音连珠炮);`restStart` 另需 `restPerSet >= 5``lastSet` 无条件播(唯一值得打断的节点)。第 1 组不播(`onStart``start` 同刻已响)。
- **`_playProgressVoice` 优先窗口(关键时序坑)**:halfway 触发点 = `sets×hold/2`,**组数为偶数时必然落在第 sets/2 组的最后一秒**,与 rest 阶段切换同一 tick;`voice.play``_playSeq` 会让后播的截断先播的 → 默认 4 组配置下"已完成一半啦"必被 `restStart` 掐断。故进度语音播放时设 `this._voiceBusyUntil = Date.now()+2600``_circuitPhase` 在窗口内让位不播。`_initCircuit` 需重置 `_voiceBusyUntil = 0`(否则"再来一次"会误静音首条过场语音)。
- **preload 分模式**`voice.HOLD_KEYS` / `voice.CIRCUIT_KEYS` 导出,`onLoad``isCircuitMode` 取,避免单组训练白白预载 3 条循环语音。
### 部署要求
**必须重新部署 `tts` 云函数**,否则 3 个新 key 云端返回 `Unknown prompt key`。降级是安全的(`_fetchUrl` 拿到 `success:false` → 返回 null → `play` 静默 return,不报错、不弹 toast),只是这 3 条不出声。首次播放会各触发一次 TTS 合成(一次性费用,之后走 fileID + 本地文件双层缓存)。
改动文件:`utils/voice.js``cloudfunctions/tts/index.js``pages/timer/timer.js`。三个 `node --check` 全过,两边词条 grep 核对一致。
## 分析(未改代码):训练时禁止息屏
用户问"训练过程中能否禁止屏幕息屏"。结论:能,`wx.setKeepScreenOn({keepScreenOn})`,基础库 1.4.0+,无授权/无域名/无审核风险。项目当前 **零处调用**grep `setKeepScreenOn` 无匹配)。
### 为什么这不只是体验优化,而是修 bug
息屏/后台后小程序 JS 定时器被系统节流挂起 → 语音播报(halfway/last30/last10、circuit 的 restStart/nextSet/lastSet)与震动**全部丢失**。`Timer`/`CircuitTimer``Date.now` 前台补偿,秒数不会错,但中段提示是废的。平板支撑用户手撑地上不碰屏幕,60 秒必息屏 → 现状几乎每次训练的过程提示都收不到。
### 挂钩位置(关键)
- `wx.setKeepScreenOn` 作用域是**整个小程序**,跨页面持续生效,只有**退出小程序**才自动失效 → 在 `pages/timer` 打开后,若不手动关,用户返回首页/记录页/排行榜也会一直常亮耗电。
- 建议:`onStart` 开启 → `onUnload` 必须关闭(timer.js 现有 `onLoad`/`onShow`/`onUnload`**无 `onHide`**)。暂停时保持常亮(用户可能只是短暂调整姿势)。完成弹窗期间也保持,离开页面才关。
- `wx.setKeepScreenOn` 是异步 API`fail` 要静默吞掉(部分 Android ROM 省电模式会拒绝),不弹 toast。
### 已知限制(需向用户说明)
- 只阻止**自动**息屏;用户手动按电源键仍会锁屏。
- 部分 Android 厂商 ROM 在省电模式/低电量下会忽略该设置。
- 不影响亮度,屏幕可能仍会自动调暗(如需要可配 `wx.setScreenBrightness`,但必须在 `onUnload` 恢复,否则污染用户系统亮度——不建议做)。
### 建议的落地范围
约 5 行,只动 `pages/timer/timer.js`;可选在设置页加「训练时屏幕常亮」开关(默认开),与 `voiceGuide` 同样存在 settings 里。
## 已实现:训练时屏幕常亮(2026-08-03 五次迭代)
用户确认落地,且明确「先不要加开关」→ 默认恒开,不入 settings。
### 实现
仅改 `pages/timer/timer.js`,新增 `_keepScreenOn(on)` 方法 + 4 个挂钩点:
- `_keepScreenOn(on)``typeof wx.setKeepScreenOn !== 'function'` 时直接 return(低基础库降级),`fail(){}` 静默吞掉(Android 省电模式会拒绝,弹 toast 只会让用户困惑)。
- `onStart()` 正常启动分支 → 开启;**暂停时不关**(用户多半在调整姿势,闪屏更烦);**完成弹窗期间不关**(让用户看成绩)。
- `onStart()``isPaused` resume 分支 → 也调一次(暂停期间若切过后台,标志位已被系统清掉)。
- `onShow()``if (this.data.isRunning) this._keepScreenOn(true)` **重新武装**。这是最容易漏的一点:退出/切后台会清掉 app 级标志,回来不重设则后半程又静音。完成后 `isRunning` 已是 false(第 831 行 setData),不会误开。
- `onUnload()`**必须关**。标志是 app 级不是页面级,不关则用户返回首页/记录/榜单全程常亮耗电。
hold 与 circuit 两种模式共用这一套(都走 onStart/onUnload),无需分别处理。`node --check` 通过。
## 分析(未改代码):首页打卡卡片左上/左下出现两条竖条
用户反馈"训练结束返回首页,顶部连续打卡卡片左上角和左下角各显示一个竖条,2-3 秒后消失"。
### 根因
`pages/index/index.wxss``.just-completed`(训练完成高亮)把 `border: 4rpx solid var(--primary) !important` + `box-shadow` 加在了 **`<ui-card>` 宿主节点**上(`index.wxml:13``class="{{justCompleted ? 'just-completed' : ''}}"`)。而 `components/ui-card/ui-card.wxss` **只给内部 `.ui-card` view 写样式,宿主节点没有任何 `display` 声明** → 宿主是 **inline 盒**
inline 盒内包 block 子元素时,inline 盒被打断成"前/后"两个零宽行盒,border 只画在这两个碎片上;左右各 4rpx 贴合 → 视觉上就是 block 上方一条、下方一条竖线,且都贴左侧 = 用户看到的"左上角+左下角两个竖条"。
时序对上:`index.js:125-129``onShow``justCompleted=true``setTimeout` 3000ms 后置回 false —— 正是那 2-3 秒。
### 连带失效(同一根因,用户还没察觉)
- `.just-completed``animation: justCompletedPulse``transform: scale`**从未生效** —— `transform` 对非替换 inline 元素无效。
- `box-shadow` 同样只画在两个零宽碎片上,橙色光晕看不见。
- 全局 grep `:host` / `display:block``components/` **零匹配** → 项目里**所有**自定义组件宿主都是 inline,任何在页面侧给组件标签加 border/shadow/transform 的写法都会踩同一个坑。
- 附带发现:`ui-card.wxss``.ui-card.in:nth-child(1..4)` 交错入场延迟**也是失效的** —— `.ui-card` 是组件内部根节点下唯一的第 1 个子元素,永远匹配 `nth-child(1)`,所有卡片延迟恒为 0.05s。要做交错必须由页面侧传 index 或用 `animation-delay` 内联 style。
### 候选修法(待用户拍板,尚未实施)
1. **推荐**`components/ui-card/ui-card.wxss``:host { display: block; }`(微信小程序支持 `:host` 选择器)。一处修复所有 ui-card 用法,border/shadow/transform 全部恢复正常。风险:宿主从 inline 变 block 可能影响其他页面既有布局,需回归各页面卡片间距。
2. 保守版:只把 `.just-completed` 的高亮样式改成作用于**内部**元素(如页面侧改成给 `.header-card` 加高亮),不动组件。影响面最小但治标不治本。
3. 顺带可给其余 6 个自定义组件统一补 `:host { display: block; }`(需逐个回归,尤其 `ui-btn` 若被当行内元素用则不能改)。
## 已实现:修复竖条 + 恢复交错入场(2026-08-03 六次迭代,用户选方案 A)
### 改动
- **`components/ui-card/ui-card.wxss`**:新增 `:host { display:block; margin-bottom:24rpx; box-sizing:border-box }`,并把 `margin-bottom:24rpx` 从内部 `.ui-card` 移除(**上提到宿主**)——否则页面侧加的描边会把 24rpx 外边距一起圈进去,框比卡片高一截。删掉失效的 `.ui-card.in:nth-child(1..4)` 规则。
- **`components/ui-card/ui-card.js`**:加 `index` 属性(Number,默认 0)。
- **`components/ui-card/ui-card.wxml`**`style="animation-delay:{{50 + (index>3?3:(index>0?index:0))*100}}ms;{{customStyle}}"`。**customStyle 必须写在最后**才能覆盖内联 delay。>3 钳到 3,避免设置页第 7 张卡等 0.65s。
- **`pages/index/index.wxss`** 的 `.just-completed``border: 4rpx solid``box-shadow: 0 0 0 4rpx var(--primary), 0 4rpx 24rpx rgba(--primary-rgb,.3)`,加 `border-radius: 24rpx`,去掉两个 `!important`(宿主上已无竞争样式)。
- **14 处调用点补 `index`**index 页 0-1records 页 0,1,2(trend 分支),2(records 分支,与 trend 互斥故同号),3settings 页 0-6。
### 校验与风险
- `ui-card.json``styleIsolation` 覆盖(默认 isolated),`:host` 有效。`customStyle` 从未用在 ui-card 上(只有 ui-btn 在 timer 页用),改动无冲突。
- `hidden="{{...}}"`(records 5 张卡)安全:微信内置 `[hidden]{display:none!important}``!important`,优先级高于 `:host``display:block``:host` 未加 !important)。
- 待真机确认:inline→block 会吃掉标签间行内空白,各页卡片间距可能略收紧几 px(属修正,非回归);`transform:scale(1.02)` 脉冲首次真正生效。
---
## 提交与推送
今日全部改动已合为一个提交推送到 `origin/main`
- commit `3532d01` `feat: 循环训练模式 + 语音补全 / 屏幕常亮 / 卡片渲染修复`23 files, +1433 / -44
- 前一个提交是 `76cd2b6`。推送后 `git rev-list --left-right --count origin/main...main``0 0`,工作区干净。
- **未拆分成多个 commit 的原因**:`pages/timer/timer.js` 被循环训练 / 顶部面板 / 语音 / 常亮四轮改动同时触及,按文件粒度无法干净切分,hunk 级拆分成本高且易错。改为一次提交、commit message 分四段写清各自根因。
- `.workbuddy/memory/*.md` 一并提交(该目录本就在版本控制内,已有 2026-07-21/22/28 三份历史日志被跟踪)。
- 推送按项目惯例走沙箱外(`dangerouslyDisableSandbox`),沙箱内会 SSL 超时。
### ⚠️ 部署待办
`cloudfunctions/tts` **尚未重新部署**。新增的 `restStart / nextSet / lastSet` 三条词条在云端词表白名单里还不存在,线上会返回 `Unknown prompt key` → 客户端静默降级(不报错、不弹窗,只是不出声)。循环训练的过场语音要生效必须先部署这个云函数。
## 首页按钮改直角(2026-08-03 末尾)
用户偏好无圆角按钮。把 `pages/index/index.wxml` 中 5 个 `ui-btn` 全加 `custom-style="border-radius:0;"`(主训练按钮 xl-primary、两个 ghost 次按钮、两个弹窗 primary-lg 按钮)。
- **实现要点**`ui-btn` 是全局共用组件且 `style isolation: isolated`,页面 wxss 改不到内部圆角;但组件暴露 `customStyle` 属性(直接挂到内部 `<button>``style`),内联 `border-radius:0` 可覆盖组件内置的 `48rpx`,**仅影响首页、不动全局与其他页面**。
- 生成 `preview-buttons.html` 对照预览(圆角 vs 直角)。
- **用户反馈直角不好看 → 改为折中 `border-radius:10rpx`812 中间值)**5 处 `custom-style` 已更新。仅首页、不动全局。预览页「改后」栏同步更新为 10rpx 效果。
- **布局调整**:首页「自由训练 / 循环训练」两个 ghost 次按钮从竖排改为并排一行。wxml 用 `<view class="action-row">` 包住两个 `<ui-btn class="action-cell">`index.wxss 加 `.action-row{display:flex;gap:16rpx}` + `.action-row .action-cell{flex:1;min-width:0}`(宿主节点为 flex 项,flex:1 各占一半,保留 `block` 让内部按钮撑满宿主)。按钮文字精简为「自由训练」「循环训练」。
- **高度对齐**:自由/循环两个 ghost 次按钮 `size``md`(80rpx) 改为 `xl`(108rpx),与主按钮 `size="xl"` 高度一致(圆角保持 16rpx)。预览页 `btn--md``btn--xl` 同步。仅首页、不动全局组件。
+20 -38
View File
@@ -1,43 +1,25 @@
# 项目长期记忆(wx_pbzc 平板支撑训练小程序)
## 关键技术陷阱
- **小程序组件 style isolation**:自定义组件默认 `isolated`。组件内 `variant` 拼出的类(`gradient`/`in` 等)在组件作用域,页面里写的 `.组件类 <slot后代>` 跨作用域选择器**全部失效**(如 `.ui-card.gradient .streak-num`)。唯一能跨边界传递的是 **CSS 自定义属性**沿 DOM 继承进 slot)。
- 推论:渐变/主色容器内要反白的文字,必须用 `var(--text)/var(--text-secondary)`(容器在 `.gradient` 里重定义过即会变白),**绝不能用 `var(--primary)`**——否则橙色字画在橙色底上=看不见
- 进度条填充同理:用 `var(--on-primary, <原渐变>)` 让容器可覆盖为白,而非依赖失效的后代选择器
- **图标 SVG 颜色是烤死在 data URI 里的**(`utils/icons.js``build()``*Fill``primary` 主题色、`*`(无 Fill)用灰 `#999`)。CSS 的 `color`/`currentColor`/变量**都改不了 `<image src>` 这种图标**。所以:
- 放在**主色/渐变底**gradient hero 卡、primary 实心按钮)上的图标必须用**白色变体**(`hotWhite`/`formWhite`/`playWhite`/`trophyWhite` 等),否则同色隐形
- 加新图标或新彩色容器时,先想清楚底色:浅色底用 `*Fill`(主题色),彩色/主色底用 `*White` 变体
- 主按钮 `ui-btn--primary` 背景是橙渐变,`playFill`(橙) 放上去即隐形——须用 `playWhite`。ghost/outline 按钮底色浅或灰,用 `*Fill`/灰色变体即可。
- `components/ui-btn` 图标尺寸 `1.3em`(随按钮字号自适应),不要回到 `1em` 以免显得过小。
- 深色模式有两套事实源,改配色前先统一:`theme.json` 的 dark 与 `app.wxss`/`utils/theme.js` 要一致(已统一到 `#131316` 系)。
- **微信圆形头像/图片裁剪陷阱(用户基础库实测)**:本项目的圆形头像在用户机器上表现如下——
- 单独给 `<image>``border-radius:50%`(无 overflow 父层)→ 圆框里露方图,失效。
- 外层 `<view>``overflow:hidden`+`border-radius:50%` 包裹 → **能裁切 SVG 占位图,但裁不掉远程照片(cloud:// 真实头像)**,照片仍方。所以日榜(多为占位图)正常、月榜/年榜(真实照片)却方。
- **唯一可靠写法**:外层 `overflow:hidden` 父层 **且** `<image>` 自身也加 `border-radius:50%`(双保险)。设置页 `.avatar-btn`(button,overflow:hidden)+`.avatar-img`(image,border-radius:50%) 即是此写法且实测正常。排行榜 `.avatar`/`.avatar__img` 已对齐这一写法。
- 推论:以后做圆形头像,**必须** image 自身 `border-radius:50%`,不能只靠父层 overflow。
- **微信系统字体缩放导致文字溢出固定盒子(模拟器不重现、真机重现)**:微信会按用户「设置→通用→字体大小」自动放大**所有文字(含 rpx 字号)**,但**不放大 rpx 盒子尺寸**width/height/padding)。症状:固定尺寸的圆形/方形容器里的文字在真机被放大、超出后被 `overflow:hidden` 裁掉,模拟器(标准档倍率 1.0)正常。
- 获取缩放:`wx.getAppBaseInfo().fontSizeScaleFactor`(当前字号÷标准 17px);旧接口 `getSystemInfoSync().fontSizeSetting`px,标准 17)。
- **真机/模拟器不对称(关键坑)**:`getAppBaseInfo().host.env` 在模拟器是 `'devtools'`、真机是 `'wechat'`。模拟器会**原样返回你手机的真实缩放倍率**,但**渲染时并不会把文字放大**;真机才会真的放大。所以若不做区分,补偿把字号缩成 `design/factor` 后:真机放大回 design(正常),模拟器按缩小值渲染(数字变小=「不正常」)。→ **必须在 `host.env === 'devtools'` 时跳过补偿、直接用设计字号**;仅在真机/PC 微信才补偿。首页 `_readFontScale()` 已加此判断。
- 补偿法:把容器内的关键文字字号反向除以该倍率(`designRpx / factor`),并在 JS 里 clamp 到安全区间(如 48–96),使真机渲染成和模拟器一致的视觉大小、永不溢出。首页 `.target-time` 已用此法(`index.js` `_readFontScale()`)。
- 配套 CSS`white-space:nowrap` 防换行顶出圆圈。
- 推论:以后凡把文字放进**固定尺寸的圆形/方形容器**且依赖 `overflow:hidden` 裁切,都必须考虑字体缩放补偿,否则真机大字体档必溢出。
- **排行榜头像 1–2s 延迟根因(设计使然,非 bug)**:`profile.avatarUrl` 存的是 `cloud://` 云存储 fileID`settings._uploadAvatar``wx.cloud.uploadFile` 返回 fileID);云函数 `leaderboard` 直接透传,前端 `<image src="cloud://...">``setData` 渲染时才把 fileID 解析成临时 https URL(等效一次 `getTempFileURL` 网络往返)再下载。文字(昵称/时长/排名)是本地字符串瞬时渲染 → 故"文字先出、头像后出",延迟正是头像专属的「云存储解析 + 原图下载」段,叠加云函数冷启动。
- 占位图 `peopleFill` 是本地 SVG data URI 瞬时,所以日榜(多为占位)无感、月/年榜(真实照片)延迟明显。
- 优化方向:①云函数返回前对 `cloud://``avatarUrl` 批量 `cloud.getTempFileURL` 预解析成 https;②`_uploadAvatar` 上传前把 `chooseAvatar` 原图压缩/裁剪到最长边 ~200px、转 jpg/webp,体积降一个数量级;③云函数保活降冷启动。
- **云函数数据库写操作必须 `{ data: {...} }` 包裹(wx-server-sdk 关键坑)**`wx-server-sdk`(本项目 2.6.3) 的 `db.collection().doc(id).set(x)` / `.update(x)` / `.add(x)` **写操作**,参数必须是 `{ data: realObj }` 包裹形式——SDK 内部读 `parameter.data` 当作要写入的文档。直接传裸对象 `set(realObj)` 会让 SDK 读 `realObj.data` === `undefined`,报 `parameter.data should be object instead of undefined`(读操作 `get()` 不受影响,裸对象/无参都行)。`leaderboard` 云函数 `_persistSnapshot` 踩过此坑:写快照全失败、集合一直空。
- 修复:`docRef.set({ data: { period, ranked, myOpenid, updatedAt } })`。写入后文档内容即 `realObj`(data 外套被 SDK 解开),读取 `doc.data` 仍是 `realObj`,读逻辑无需改。
- 推论:**以后任何云函数写库代码,先写 `{ data: ... }`**,别沿用客户端 SDK 的裸对象习惯;出错时先怀疑是不是漏了 data 包裹,而不是怀疑部署。
## 关键技术陷阱(最高优先级,改样式/加组件前必读)
- **组件 style isolation = `isolated`**:页面 wxss 改不了组件内部类;唯一能跨边界的是 **CSS 变量**(沿 DOM 继承进 slot)。渐变/主色容器内反白文字必须用 `var(--text)`**绝不**用 `var(--primary)`(橙字画橙底=隐形)。进度条填充用 `var(--on-primary,...)` 让容器可覆盖为白。
- **图标色烤死在 SVG data URI**`utils/icons.js``*Fill`=主题色、`*`(无Fill)=灰 `#999`),CSS 改不了 `<image>`。主色/渐变底(hero 卡、primary 按钮)须用 `*White` 变体(如 `playWhite`),浅底用 `*Fill`。**随状态换色的小装饰用纯 CSS 画**(如 `.circuit-phase-dot`),别用 icon
- **圆形头像裁剪**:必须「外层 `overflow:hidden` + `<image>` 自身 `border-radius:50%`」双保险,否则远程照片(cloud://)裁不掉
- **系统字体缩放致文字溢出固定盒**(真机重现、模拟器不重现):`getAppBaseInfo().host.env==='devtools'` 时跳过补偿,真机/PC 才把字号 `÷factor` 并 clamp。凡文字进固定圆/方盒且靠 `overflow:hidden` 裁切,都要考虑。
- **云函数写库必须 `{ data: {...} }` 包裹**wx-server-sdk 2.6.3):`set/update/add` 漏包裹报 `parameter.data should be object`。读操作 `get()` 无此限制
- **自定义组件宿主默认 inline**:页面给 `<ui-card>` 等标签加 `border`/`transform`/`box-shadow` 会碎裂或静默失效(仅 `ui-card` 已修 `:host{display:block}`)。描边优先用 `box-shadow:0 0 0 Nrpx` 并补 `border-radius`,别用 `border`。跨组件边界的 `nth-child` 选择器一律失效,交错延迟由页面传 `index` 驱动
## 排行榜架构(2026-07-28 落地 P0/P1/P2 后
- **预计算快照**`cloudfunctions/leaderboard``exports.main` 现在区分两种调用——
- **定时触发器**`config.json``triggers``snapshotTimer` / `0 */4 * * * * *`,每 4 分钟)进入 `_rebuildSnapshots()`:扫描一次 `plank_data``_fetchLatestByOpenid`,走 updatedAt 索引+投影),复用 `_buildFromScan()` 算 day/month/year 三榜,各自写进 `leaderboard_snapshot` 集合(doc._id=周期,字段 `period/ranked/myOpenid/updatedAt`;首次写自动建集合)。
- **客户端请求**:默认走 `_serveFromSnapshot()`1 次 `get` ≈ 50ms),快照缺失/过期(>6min)才 `_computeBoard()` 实时重算并回写。**首开/冷启动已从 2-3s 降到 ≈50ms。**
- **force 语义**:仅训练后那一次(客户端 `onShow``_lb_force_refresh``force=true`)走 `_computeBoard(force:true,persist:true)` 实时重算+回写快照;其他 99% 请求读快照,已满足"force 不再绕过云缓存"诉求。
- **P1 启动预热**`app.js` `onLaunch``cloud.init()` 后 fire-and-forget 调一次 `leaderboard`(day),提前暖云实例/快照。
- **头像延迟**:云函数内 `getTempFileURL` 预解析已落地(在 `_buildFromScan` 里),前端不再懒加载,故"文字先出头像后出"的 1-2s 延迟已大幅消除;快照每 4min 重写,临时 URL 始终新鲜
- **改动注意**:要生效必须**重新部署 `leaderboard` 云函数**(定时器随部署注册)。`plank_data.updatedAt` 单字段索引需在控制台已建(用户已建)。响应结构 `{period,ranked,myOpenid,myEntry,updatedAt}` 不变,客户端 `leaderboard.js` 无需改逻辑(仅注释更新)
## 排行榜架构(2026-07-28+,云函数 `cloudfunctions/leaderboard`
- **预计算快照**定时触发器(每 4min`_rebuildSnapshots()``plank_data` 算 day/month/year/endurance 四榜写 `leaderboard_snapshot`;客户端 99% 走 `_serveFromSnapshot()`(≈50ms),仅训练后 `force=true` 那次实时重算+回写。**改后须重新部署云函数**(定时器随部署注册)。
- **耐力榜(endurance)**:全局单次最久,取 `max(duration)` 并记 `bestDate`;本人未进 top10 由 my-bar 在底部显示(依赖快照存 top500)。
- 头像延迟已通过云函数 `getTempFileURL` 预解析解决;`plank_data.updatedAt` 需建单字段索引。
## 语音 / 常亮 / 循环训练
- **TTS 词条两份白名单**`utils/voice.js``cloudfunctions/tts/index.js`)须同步;加词条后**必须重新部署 tts 云函数**。词条避免含数字/变量(如"第3组"),用通用句
- **`voice.play` 截断前一条**`_playSeq`):circuit halfway 与 rest 切换同刻须用 `_voiceBusyUntil` 让位;每组<10s 不播过场
- **`wx.setKeepScreenOn` 是小程序级、切后台失效**`onUnload` 须显式关,`onShow``isRunning` 须重新武装(否则后半程息屏丢语音/震动)。
- **循环训练 circuit**2026-08-03):自由训练的循环形态,不碰 `plan.js`。入口首页 ghost 按钮→配置弹窗→`timer?circuit=1&...``duration`=组数×每组秒(不含休息);记录 `mode:'circuit'``planId:'circuit'`(计入连胜不计入计划进度)。状态机 `utils/circuitTimer.js`
## 约定
- UI 动词说法:index=首页/训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
- 美化按"见效快优先"分批,每轮 node --check + grep 残留再交付。
- **git 推送需走 sandbox 外**:本机 Bash 工具默认 sandbox 隔离网络,`git push` 到 github.com 会 `SSL connection timeout`(约 5 分钟才报错)。必须用 `dangerouslyDisableSandbox: true` 在沙箱外执行推送(或 `run_in_background` + 沙箱外)才能连上。无 SSH keyremote 仅 HTTPS
- 页面叫法:index=训练首页,timer=训练页,leaderboard=排行榜,records=记录,settings=设置。
- 美化按"见效快优先"分批,每轮 `node --check` + grep 残留再交付。
- **git push 须 `dangerouslyDisableSandbox:true`**(沙箱隔离致 github SSL 超时);无 SSH,仅 HTTPS remote
@@ -0,0 +1,33 @@
# 循环训练(简化版 + 配置持久化)实现概览
## 做了什么
在「自由训练」并列新增**「循环训练」入口**,让用户自定义 每组时长 / 组数 / 组间休息(外加每周目标次数,仅作显示),并把这些设置**本地持久化**——下次打开直接沿用,无需重填。这正是你要的"简化版 + 至少要有循环训练设置的持久化"。
## 核心设计
- **数据模型零入侵**:循环记录以 `planId:'circuit'` 落库,`getPlanDay` 不会把它算进每日计划进度;`duration` 仍存"有效撑总秒 = 组数×每组秒"(不含休息),排行榜 / 里程碑 / 统计零改动。
- **状态机独立**`utils/circuitTimer.js``idle→working→resting→…→done` 的 FSM,复用现有 `Timer``Date.now` + `onAppShow` 前台补偿风格;`stop()` 返回有效撑总秒,直接复用 timer 页现有保存逻辑。
- **配置持久化**`storage.getCircuitConfig / saveCircuitConfig` 存本地,含默认值与范围校验,不进云同步(纯设备偏好)。
- **阶段提示**:语音只有固定 6 个 key、无"第N组 / 休息"词,故阶段切换用**震动 + 屏上文本**提示,完成复用 `complete` 语音——不过度依赖 TTS。进度环在循环模式恒中性灰(避免每阶段都"已达标"变绿误导)。
## 改动文件
| 文件 | 改动 |
|---|---|
| `utils/storage.js` | 加 circuit 配置读写(本地持久化) |
| `utils/circuitTimer.js` | 新:循环状态机 |
| `pages/timer/timer.{js,wxml,wxss}` | circuit 模式分支、状态条、阶段提示、完成/保存 |
| `pages/index/index.{js,wxml,wxss}` | 「循环训练」入口 + 配置弹窗(stepper + 持久化) |
| `utils/icons.js` | 加 `repeat` / `repeatFill` 图标 |
| `pages/records/records.{js,wxml}` | 循环记录显示「循环 N组×Ms」 |
## 验证
- 6 个 JS 文件全部通过 `node --check`
- 未改动 `plan.js` / 云函数 / 榜单 / 云同步逻辑,首提风险低。
## 未做(可选后续)
- 周频次硬追踪(仅存 `sessionsPerWeek` 作显示,未做"本周 X/N 次"计数)
- 逐周自动进阶
- 设置页统一管理入口
## 真机注意
- 环形进度用 countUp 动画,不进固定 rpx 盒,规避字体缩放溢出(首页 `_readFontScale` 思路已避开)。
- 主色 / 渐变底图标用 White 变体;`repeatFill` 为橙主题色,在 ghost 按钮浅底 OK。
+35 -2
View File
@@ -149,6 +149,37 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
for (const doc of latestByOpenid.values()) {
const openid = doc._openid || 'unknown'
const records = doc.records || {}
if (period === 'endurance') {
// 全局单次最久:遍历该用户全部月份的 records,取 max(duration) 那条,
// 并记下其 r.date 作为"产生时间"。不做日/月/年过滤、不累加。
let bestDuration = 0
let bestDate = ''
let total = 0
for (const monthKey of Object.keys(records)) {
const arr = records[monthKey]
if (!Array.isArray(arr)) continue
for (const r of arr) {
if (!r || typeof r.date !== 'string') continue
total++
const dur = Number(r.duration) || 0
if (dur > bestDuration) { bestDuration = dur; bestDate = r.date }
}
}
if (bestDuration > 0) {
const profile = doc.profile || {}
userMap.set(openid, {
openid,
duration: bestDuration,
bestDate,
sessions: total,
nickname: profile.nickname || '',
avatarUrl: profile.avatarUrl || ''
})
}
continue
}
let duration = 0
let sessions = 0
@@ -208,6 +239,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
name: _displayName(item),
duration: item.duration,
sessions: item.sessions,
bestDate: item.bestDate || '',
avatarUrl: item.avatarUrl || ''
}))
@@ -231,6 +263,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => {
name: _displayName(myEntryRaw),
duration: myEntryRaw.duration,
sessions: myEntryRaw.sessions,
bestDate: myEntryRaw.bestDate || '',
avatarUrl: myEntryRaw.avatarUrl || ''
} : null
@@ -294,7 +327,7 @@ const _serveFromSnapshot = async (period, limit, myOpenid) => {
*/
const _rebuildSnapshots = async () => {
const latestByOpenid = await _fetchLatestByOpenid(true)
for (const p of ['day', 'month', 'year']) {
for (const p of ['day', 'month', 'year', 'endurance']) {
const result = await _buildFromScan(latestByOpenid, p, SNAPSHOT_TOP, null)
console.log('[leaderboard] rebuilt', p, 'rankedLen=', (result && result.ranked) ? result.ranked.length : 'undef')
await _persistSnapshot(p, result)
@@ -328,7 +361,7 @@ exports.main = async (event) => {
const { period, maxRank, force } = event || {}
const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500))
if (!period) return { err: 'missing period' }
if (period !== 'day' && period !== 'month' && period !== 'year') return { err: 'invalid period' }
if (period !== 'day' && period !== 'month' && period !== 'year' && period !== 'endurance') return { err: 'invalid period' }
const myOpenid = cloud.getWXContext().OPENID
+6 -1
View File
@@ -26,7 +26,12 @@ const PROMPTS = {
last30: '最后30秒,保持呼吸,稳住姿势!',
last10: '最后10秒,再加把劲!',
goal: '目标达成,继续挑战!',
complete: '太棒了!今天的目标已完成,继续加油!'
complete: '太棒了!今天的目标已完成,继续加油!',
// 循环训练(circuit)阶段切换专用。必须与 utils/voice.js 的 PROMPTS 保持一致,
// 否则客户端能请求、云端返回 Unknown prompt key(会静默降级为不播)。
restStart: '休息一下,调整呼吸',
nextSet: '下一组,准备开始',
lastSet: '最后一组,全力冲刺!'
}
const CACHE_DIR = 'tts-cache'
@@ -59,7 +59,9 @@ Component({
const recordMap = {}
if (records && records.length) {
records.forEach((r) => {
const d = parseInt(r.date.split('-')[2])
// 先用 util.dateOnly 归一化日期(兼容 "YYYY-MM-DD HH:MM:SS" / ISO 等),
// 再稳定地取出「日」,避免直接 split('-')[2] 取到 "DD HH:MM:SS" 靠 parseInt 侥幸解析的脆弱写法
const d = parseInt(util.dateOnly(r.date).split('-')[2], 10)
recordMap[d] = (recordMap[d] || 0) + r.duration
})
}
+7 -4
View File
@@ -1,3 +1,5 @@
const { getBarPercent } = require('../../utils/util')
/**
* Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's
* rendering approach for consistency.
@@ -8,12 +10,13 @@
* - valueText: formatted string shown above the bar (e.g. "1分30秒")
* - highlight: truthy -> theme glow + bold label (today / this month)
*
* Bar height is value/max*100%, with an 8% floor for any non-zero value so
* short sessions stay visible. Zero values render a faint stub.
* Bar height is strictly proportional to value/max. Zero values render a
* faint stub so empty dates remain visible without distorting the data.
*/
Component({
properties: {
chartData: { type: Array, value: [] }
chartData: { type: Array, value: [] },
variant: { type: String, value: 'total' }
},
data: {
bars: []
@@ -39,7 +42,7 @@ Component({
value: v,
valueText: (d && d.valueText) || '',
highlight: !!(d && d.highlight),
percent: max > 0 ? Math.max((v / max) * 100, v > 0 ? 8 : 0) : 0
percent: getBarPercent(v, max)
}
})
this.setData({ bars })
+1 -1
View File
@@ -1,4 +1,4 @@
<view class="trend-chart">
<view class="trend-chart {{variant === 'peak' ? 'peak' : ''}}">
<view class="trend-bars">
<view class="trend-bar-col" wx:for="{{bars}}" wx:key="label">
<view class="trend-bar {{item.value === 0 ? 'zero' : ''}} {{item.highlight ? 'highlight' : ''}}" style="height: {{item.percent}}%;">
+28 -2
View File
@@ -36,14 +36,40 @@
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.5);
}
.trend-chart.peak .trend-bars {
height: 156rpx;
}
.trend-chart.peak .trend-bar {
width: 12rpx;
margin: 0 auto;
min-height: 8rpx;
border-radius: 999rpx;
}
.trend-chart.peak .trend-bar::after {
content: '';
position: absolute;
top: -8rpx;
left: -4rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: var(--primary);
}
.trend-chart.peak .trend-bar.zero::after {
background: var(--border);
}
/* value caption floats above the bar so it never eats into bar height */
.trend-bar-value {
position: absolute;
top: -28rpx;
top: -26rpx;
left: 0;
right: 0;
text-align: center;
font-size: 18rpx;
font-size: 16rpx;
color: var(--text-secondary);
line-height: 1;
white-space: nowrap;
+6 -2
View File
@@ -1,8 +1,12 @@
Component({
options: { multipleSlots: true },
properties: {
variant: { type: String, value: '' }, // '' | 'soft' | 'in'
variant: { type: String, value: '' }, // '' | 'soft' | 'in' | 'gradient'
padded: { type: Boolean, value: true },
customStyle: { type: String, value: '' }
customStyle: { type: String, value: '' },
// 入场交错序号(0 起)。组件无从得知自己是页面里的第几张卡,:nth-child 在组件
// 内部又永远命中第 1 个,所以延迟只能由页面显式传入。>3 的一律钳到 3,避免
// 卡片多的页面(如设置页 7 张)最后一张要等 0.65s 才出现。
index: { type: Number, value: 0 }
}
})
+5 -1
View File
@@ -1,3 +1,7 @@
<view class="ui-card {{variant}} {{padded ? '' : 'ui-card--flush'}}" style="{{customStyle}}">
<!-- customStyle 写在最后,保证页面传入的样式能覆盖内联的 animation-delay -->
<view
class="ui-card {{variant}} {{padded ? '' : 'ui-card--flush'}}"
style="animation-delay:{{50 + (index > 3 ? 3 : (index > 0 ? index : 0)) * 100}}ms;{{customStyle}}"
>
<slot></slot>
</view>
+17 -7
View File
@@ -1,8 +1,20 @@
/* 宿主节点必须显式声明 display —— 自定义组件宿主默认是 inline,而内部是 block。
inline 盒被 block 子元素打断后会碎成「前半段/后半段」两个零宽行盒,页面侧一旦
在 <ui-card> 标签上加 border / box-shadow,就只会画在这两个碎片上,看起来是
「角上冒出两根竖条」(首页 .just-completed 高亮踩过这个坑)transform 对非替换
inline 元素更是完全无效,缩放脉冲动画一次都不会跑。
margin-bottom 也一并上提到宿主:外边距归宿主、内边距归内部,页面侧加边框时
才不会把 24rpx 外边距一起圈进框里。 */
:host {
display: block;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.ui-card {
background: var(--card-bg, #FFFFFF);
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
box-sizing: border-box;
}
@@ -41,16 +53,14 @@
--on-primary: #FFFFFF;
}
/* Staggered entrance animation */
/* Staggered entrance animation.
交错延迟由页面传入的 index 换算成内联 animation-delay(见 ui-card.wxml)
不能用 :nth-child —— .ui-card 在组件内部永远是根节点下唯一的第 1 个子元素,
恒匹配 nth-child(1),所有卡片延迟都会是同一个值,压根不交错。 */
.ui-card.in {
animation: uiCardIn 0.45s ease both;
}
.ui-card.in:nth-child(1) { animation-delay: 0.05s; }
.ui-card.in:nth-child(2) { animation-delay: 0.15s; }
.ui-card.in:nth-child(3) { animation-delay: 0.25s; }
.ui-card.in:nth-child(4) { animation-delay: 0.35s; }
@keyframes uiCardIn {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }
+10 -4
View File
@@ -4,10 +4,10 @@
*/
module.exports = {
/** 应用版本号,外显在设置页「关于」 */
version: 'v3.02',
version: 'v3.1',
/** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-07-29',
updatedAt: '2026-08-03',
/** 开发者名称 / 微信号,设置页点击可复制 */
developer: '三口一瓶',
@@ -21,6 +21,12 @@ module.exports = {
/** 排行榜最大显示人数 */
leaderboardMaxRank: 50,
/**
* 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观,
* 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。
*/
leaderboardPodiumSlogan: '平板支撑不是一场和时间的恋爱,撑得越久,不一定越好。真正重要的,是每一秒都没有辜负身体。',
/**
* 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。
* 设置页可选男声/女声,客户端按选择取下面对应的一组传给 tts 云函数。
@@ -56,8 +62,8 @@ module.exports = {
/**
* 累计训练天数里程碑勋章,按累计去重训练日(可中断,非连续打卡)解锁。
* 记录页用里程碑进度条展示,节点按 sqrt 感知映射分布(早期里程碑更显眼),
* 填充与节点同映射,解锁时填充正好抵达该节点、满级时满格。增删改这里即可,
* 记录页用等距里程碑进度条展示,每个连接段按当前阶段进度填充为绿色。
* 解锁下一枚勋章时,对应连接段恰好填满。增删改这里即可,
* 口径=累计去重训练日(可中断);name 仅作趣味命名,不暗示连续打卡。
*/
trainingDayBadges: [
@@ -0,0 +1,30 @@
# IconPark 勋章视觉替换设计
## 目标
替换记录页训练勋章的现有通用图标,使其形成更清晰的成长叙事,同时保留主题换色、解锁状态、勋章点击说明和现有进度计算。
## 已确认的视觉方向
采用 IconPark 的彩色填充风格(预览方案 A)。图标序列为:火焰、闪电、徽章、奖杯、钻石、星耀、皇冠。
- 前三枚已解锁图标使用暖橙、金黄、主题绿的温和递进,强调早期坚持。
- 第四至第七枚解锁图标使用当前主题色,保持换肤一致性。
- 未解锁图标沿用浅灰描边圆形节点。
- 图标在本地以 SVG path 形式嵌入既有 `utils/icons.js`,不添加 npm 包、远程请求或新的运行时模块。
## 数据和交互
`config.trainingDayBadges` 继续定义天数、名称和图标键;记录页进度、逐段填充、解锁庆祝和点击详情均不改变。
图标构建器新增每枚勋章的彩色/白色变体:节点解锁时使用彩色图标,主题节点上的图标保持白色以保证对比度;未解锁时使用灰色图标。
## 验证
- 为图标构建结果补充单元测试,验证七枚勋章的键均存在。
- 运行完整 Node 测试与 JavaScript 语法检查。
- 在微信开发者工具检查默认主题及蓝、绿主题下的记录页:节点、文字、进度段和点击说明均可见。
## 授权
图标来源为 ByteDance IconParkApache-2.0;项目保留来源和许可证说明。
+61 -7
View File
@@ -15,7 +15,6 @@ Page({
todayDone: false,
todayDuration: 0,
todayTargetText: '',
homeTip: '',
// Ring number font size in rpx. WeChat auto-scales text by the user's
// font-size setting (fontSizeScaleFactor) but does NOT scale rpx box
// dimensions — so on a real device with enlarged system font the 84rpx
@@ -35,6 +34,12 @@ Page({
presets: [30, 60, 90, 120, 180, 300],
customDuration: 60,
customInput: '',
// --- Circuit (循环训练) config panel (persisted via storage.saveCircuitConfig) ---
showCircuitPicker: false,
cHold: 30,
cSets: 4,
cRest: 15,
cSessions: 3,
icons: iconsMod.build()
},
@@ -148,10 +153,6 @@ Page({
// 分段格子(每天一格)只在计划天数 <= 10 时用;更长则退化为里程碑连续条,避免格子过密
const useSegments = plan.totalDays > 0 && plan.totalDays <= 10
const planDays = useSegments ? Array.from({ length: plan.totalDays }, (_, i) => i) : []
// 首页姿势要领:从 config.postureTips 随机取一条,每次刷新都换,
// 让用户反复进首页能看到不同要领。配置为空时回退到默认文案,避免空白。
const tips = config.postureTips || []
const homeTip = tips.length ? tips[Math.floor(Math.random() * tips.length)] : '保持身体成一条直线,核心收紧,均匀呼吸'
this.setData({
theme,
icons: iconsMod.build(theme),
@@ -165,8 +166,7 @@ Page({
planTotal: plan.totalDays,
planProgress,
useSegments,
planDays,
homeTip
planDays
})
},
@@ -212,6 +212,60 @@ Page({
wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` })
},
/**
* 循环训练入口:打开配置弹窗,预填上次保存的设置(getCircuitConfig 含默认值
* + 校验),用户改完确认后持久化并跳计时器。设置因此被保存下来——下次打开直接
* 沿用,无需重填。
*/
onCircuitTrain() {
const cfg = storage.getCircuitConfig()
this.setData({
showCircuitPicker: true,
cHold: cfg.holdPerSet,
cSets: cfg.sets,
cRest: cfg.restPerSet,
cSessions: cfg.sessionsPerWeek
})
},
onCloseCircuitPicker() {
this.setData({ showCircuitPicker: false })
},
onCircuitStep(e) {
const field = e.currentTarget.dataset.field
const delta = Number(e.currentTarget.dataset.delta) || 0
const keyMap = { hold: 'cHold', sets: 'cSets', rest: 'cRest', sessions: 'cSessions' }
const key = keyMap[field]
if (!key) return
const bounds = {
hold: [5, 600],
sets: [1, 50],
rest: [0, 600],
sessions: [1, 14]
}
let v = this.data[key] + delta
const min = bounds[field][0]
const max = bounds[field][1]
if (v < min) v = min
if (v > max) v = max
this.setData({ [key]: v })
},
onConfirmCircuit() {
const cfg = {
holdPerSet: this.data.cHold,
sets: this.data.cSets,
restPerSet: this.data.cRest,
sessionsPerWeek: this.data.cSessions
}
storage.saveCircuitConfig(cfg)
this.setData({ showCircuitPicker: false })
wx.navigateTo({
url: `/pages/timer/timer?circuit=1&hold=${cfg.holdPerSet}&sets=${cfg.sets}&rest=${cfg.restPerSet}&sessions=${cfg.sessionsPerWeek}`
})
},
/**
* 分享给朋友。微信在用户点击右上角"···"菜单里的"转发给朋友"、
* 或者页面内任何 `<button open-type="share">` / `<button open-type="share">`
+78 -13
View File
@@ -10,7 +10,7 @@
</view>
<!-- 连续打卡卡片:火焰连胜 + 分段进度 -->
<ui-card variant="gradient in" class="{{justCompleted ? 'just-completed' : ''}}">
<ui-card variant="gradient in" index="0" class="{{justCompleted ? 'just-completed' : ''}}">
<view class="header-card">
<!-- 上区:连胜视觉重心 + 计划徽章 -->
<view class="streak-row">
@@ -54,7 +54,7 @@
</ui-card>
<!-- 今日目标卡片 -->
<ui-card variant="in">
<ui-card variant="in" index="1">
<view class="target-section">
<view class="target-header">
<image class="section-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
@@ -89,25 +89,33 @@
text="{{todayDone ? '再次训练' : '开始训练'}}"
icon-src="{{icons.playWhite}}"
bindtap="onStartTrain"
custom-style="border-radius:16rpx;"
></ui-btn>
<view class="action-row">
<ui-btn
class="action-cell"
variant="ghost"
size="xl"
block
text="自由训练"
icon-src="{{icons.timeFill}}"
bindtap="onFreeTrain"
custom-style="border-radius:16rpx;"
></ui-btn>
<ui-btn
class="action-cell"
variant="ghost"
size="md"
size="xl"
block
text="自由训练 · 自定义时长"
icon-src="{{icons.timeFill}}"
bindtap="onFreeTrain"
text="循环训练"
icon-src="{{icons.repeatFill}}"
bindtap="onCircuitTrain"
custom-style="border-radius:16rpx;"
></ui-btn>
</view>
<!-- 小提示 -->
<ui-card variant="soft" padded="{{false}}">
<view class="tip-card">
<image class="tip-icon" src="{{icons.lightFill}}" mode="aspectFit"></image>
<text class="tip-text">{{homeTip}}</text>
</view>
</ui-card>
<!-- 时间选择弹窗 -->
<ui-modal
@@ -147,7 +155,64 @@
block
text="开始自由训练"
bindtap="onConfirmFree"
custom-style="border-radius:16rpx;"
></ui-btn>
<text class="picker-cancel" bindtap="onClosePicker">取消</text>
</ui-modal>
<!-- 循环训练配置弹窗:每组时长 / 组数 / 休息 / 每周次数,保存后持久化 -->
<ui-modal
visible="{{showCircuitPicker}}"
position="bottom"
bind:close="onCloseCircuitPicker"
>
<text class="picker-title">循环训练设置</text>
<text class="picker-sub">自定义每组时长、组数与组间休息</text>
<view class="circuit-row">
<text class="circuit-row-label">每组时长</text>
<view class="stepper">
<view class="stepper-btn" data-field="hold" data-delta="-5" bindtap="onCircuitStep"></view>
<text class="stepper-val">{{cHold}}秒</text>
<view class="stepper-btn" data-field="hold" data-delta="5" bindtap="onCircuitStep">+</view>
</view>
</view>
<view class="circuit-row">
<text class="circuit-row-label">组数</text>
<view class="stepper">
<view class="stepper-btn" data-field="sets" data-delta="-1" bindtap="onCircuitStep"></view>
<text class="stepper-val">{{cSets}}组</text>
<view class="stepper-btn" data-field="sets" data-delta="1" bindtap="onCircuitStep">+</view>
</view>
</view>
<view class="circuit-row">
<text class="circuit-row-label">组间休息</text>
<view class="stepper">
<view class="stepper-btn" data-field="rest" data-delta="-5" bindtap="onCircuitStep"></view>
<text class="stepper-val">{{cRest}}秒</text>
<view class="stepper-btn" data-field="rest" data-delta="5" bindtap="onCircuitStep">+</view>
</view>
</view>
<view class="circuit-row">
<text class="circuit-row-label">每周目标</text>
<view class="stepper">
<view class="stepper-btn" data-field="sessions" data-delta="-1" bindtap="onCircuitStep"></view>
<text class="stepper-val">{{cSessions}}次/周</text>
<view class="stepper-btn" data-field="sessions" data-delta="1" bindtap="onCircuitStep">+</view>
</view>
</view>
<ui-btn
variant="primary"
size="lg"
block
text="保存并开始"
bindtap="onConfirmCircuit"
custom-style="border-radius:16rpx;"
></ui-btn>
<text class="picker-cancel" bindtap="onCloseCircuitPicker">取消</text>
</ui-modal>
</view>
+71 -20
View File
@@ -313,27 +313,15 @@
margin: 8rpx 0 8rpx;
}
/* ---- tip card ---- */
.tip-card {
/* 自由训练 / 循环训练 并排一行 */
.action-row {
display: flex;
align-items: flex-start;
padding: 24rpx 32rpx;
background: transparent;
border-radius: 20rpx;
gap: 16rpx;
}
.tip-icon {
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
flex-shrink: 0;
align-self: center;
}
.tip-text {
font-size: 28rpx;
color: var(--text-secondary);
line-height: 1.6;
.action-row .action-cell {
flex: 1;
min-width: 0; /* 防止图标+文字在窄屏撑破 flex 项 */
}
/* ---- picker ---- */
@@ -427,11 +415,74 @@
margin-top: 8rpx;
}
/* ---- circuit (循环训练) config panel ---- */
.picker-sub {
display: block;
text-align: center;
font-size: 24rpx;
color: var(--text-secondary);
margin: -18rpx 0 24rpx;
}
.circuit-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 8rpx;
border-bottom: 1rpx solid var(--border);
}
.circuit-row-label {
font-size: 30rpx;
color: var(--text);
font-weight: 500;
}
.stepper {
display: flex;
align-items: center;
gap: 20rpx;
}
.stepper-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: var(--bg-soft);
color: var(--primary);
font-size: 40rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: transform 0.12s ease, opacity 0.12s ease;
}
.stepper-btn:active {
transform: scale(0.9);
opacity: 0.8;
}
.stepper-val {
min-width: 124rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
}
/* ---- 训练完成高亮 (3 秒后淡出) ---- */
/* 这个 class 落在 <ui-card> 宿主节点上(ui-card.wxss 已把宿主设为 display:block
否则 border/box-shadow 只会碎成两根竖条、transform 完全不生效)。
描边用 box-shadow 的 spread 而非 borderborder 会撑大宿主 8rpx,高亮出现/消失
时整页布局跳动;box-shadow 不占布局空间。宿主自身无圆角,必须补上与内部卡片
一致的 24rpx,否则描边会是直角框套在圆角卡片外面。 */
.just-completed {
animation: justCompletedPulse 1s ease-in-out 2;
border: 4rpx solid var(--primary) !important;
box-shadow: 0 4rpx 24rpx rgba(var(--primary-rgb), 0.3) !important;
border-radius: 24rpx;
box-shadow: 0 0 0 4rpx var(--primary), 0 4rpx 24rpx rgba(var(--primary-rgb), 0.3);
}
@keyframes justCompletedPulse {
+82 -14
View File
@@ -9,11 +9,13 @@ Page({
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
themeStyle: themeMod.BASE_VARS,
periods: [
{ key: 'day', label: '日榜' },
{ key: 'month', label: '月榜' },
{ key: 'year', label: '年榜' }
{ key: 'day', label: '日榜', maxRank: config.leaderboardMaxRank },
{ key: 'month', label: '月榜', maxRank: config.leaderboardMaxRank },
{ key: 'year', label: '年榜', maxRank: config.leaderboardMaxRank },
{ key: 'endurance', label: '耐力', maxRank: 10 }
],
activePeriod: 'day',
podiumSlogan: config.leaderboardPodiumSlogan,
rankedList: [],
myEntry: null,
loading: false,
@@ -68,6 +70,8 @@ Page({
this._fetchSeq = (this._fetchSeq || 0) + 1
const seq = this._fetchSeq
const period = this.data.activePeriod
const periodObj = this.data.periods.find(p => p.key === period)
const maxRank = (periodObj && periodObj.maxRank) || config.leaderboardMaxRank
// 乐观缓存:命中该 period 的近期缓存就先秒显旧数据,后台再静默拉新。
// 命中时不进 loading 骨架屏(避免旧数据被空白覆盖),用 refreshing 标记
@@ -91,7 +95,7 @@ Page({
name: 'leaderboard',
data: {
period,
maxRank: config.leaderboardMaxRank,
maxRank,
force: !!force
}
}).then(res => {
@@ -116,15 +120,35 @@ Page({
},
_applyResult(result) {
const list = (result.ranked || []).map(item => ({
...item,
durationText: util.formatDuration(item.duration)
}))
const myEntry = result.myEntry ? {
const isEndurance = this.data.activePeriod === 'endurance'
const list = (result.ranked || []).map(item => {
const base = { ...item, durationText: util.formatDuration(item.duration) }
if (isEndurance) {
const { date, time } = this._splitBestDate(item.bestDate)
base.subText = this._formatBestDate(item.bestDate)
base.subDate = date
base.subTime = time
} else {
base.subText = `${item.sessions}`
}
return base
})
const myEntry = result.myEntry ? (() => {
const base = {
...result.myEntry,
durationText: util.formatDuration(result.myEntry.duration),
isMe: true
} : null
}
if (isEndurance) {
const { date, time } = this._splitBestDate(result.myEntry.bestDate)
base.subText = this._formatBestDate(result.myEntry.bestDate)
base.subDate = date
base.subTime = time
} else {
base.subText = `${result.myEntry.sessions}`
}
return base
})() : null
const empty = list.length === 0 && !myEntry
this.setData({
rankedList: list,
@@ -141,11 +165,34 @@ Page({
_applyLocal() {
const period = this.data.activePeriod
const allRecords = Object.values(storage.getRecords()).flat()
const profile = storage.getProfile()
const localName = profile.nickname || '我'
// 耐力榜本地兜底:取本地全部记录里单次最久 + 其日期
if (period === 'endurance') {
let bestDuration = 0
let bestDate = ''
allRecords.forEach(r => {
const dur = Number(r.duration) || 0
if (dur > bestDuration) { bestDuration = dur; bestDate = r.date }
})
if (bestDuration > 0) {
const { date, time } = this._splitBestDate(bestDate)
const entry = {
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
duration: bestDuration, durationText: util.formatDuration(bestDuration),
bestDate, subText: this._formatBestDate(bestDate), subDate: date, subTime: time
}
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
} else {
this.setData({ loading: false, refreshing: false, empty: true })
}
return
}
const today = storage.getToday()
const thisMonth = today.substring(0, 7)
const thisYear = String(new Date().getFullYear())
const profile = storage.getProfile()
const localName = profile.nickname || '我'
let duration = 0
let sessions = 0
@@ -158,7 +205,8 @@ Page({
if (duration > 0) {
const entry = {
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
duration, durationText: util.formatDuration(duration), sessions
duration, durationText: util.formatDuration(duration), sessions,
subText: `${sessions}`
}
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
} else {
@@ -166,6 +214,26 @@ Page({
}
},
/**
* 把记录里的 "YYYY-MM-DD HH:MM:SS" 截成 "YYYY-MM-DD HH:MM"(去掉秒),
* 纯日期串原样返回。耐力榜用其展示单次的"产生时间"。
*/
_formatBestDate(s) {
if (!s || typeof s !== 'string') return ''
if (s.length >= 16) return s.substring(0, 16)
return s
},
/**
* 把 "YYYY-MM-DD HH:MM"(来自 _formatBestDate)拆成
* { date: 'YYYY-MM-DD', time: 'HH:MM' },供领奖台底座两行展示耐力榜的"单次日期"。
*/
_splitBestDate(s) {
const f = this._formatBestDate(s)
if (f && f.length >= 11) return { date: f.substring(0, 10), time: f.substring(11) }
return { date: f || '', time: '' }
},
/**
* 分享给朋友 — 排行榜页。把用户当前名次写进标题,有"攀比"属性,
* 分享欲望会强一些。
@@ -175,7 +243,7 @@ Page({
const s = config.share.leaderboard
// myEntry.rank 依赖云函数返回,缺失时回退静态 title,避免出现"排第 undefined"
if (me && me.rank) {
const periodLabel = { day: '日', month: '月', year: '年' }[this.data.activePeriod] || ''
const periodLabel = { day: '日', month: '月', year: '年', endurance: '耐力' }[this.data.activePeriod] || ''
return {
title: s.titleTemplate.replace('{period}', periodLabel).replace('{rank}', me.rank),
path: s.path
+8 -5
View File
@@ -41,6 +41,9 @@
</view>
<block wx:else>
<!-- 耐力榜领奖台顶部文案:哲理注脚,点出"撑得越久不一定越好" -->
<view class="podium-slogan" wx:if="{{activePeriod === 'endurance'}}">{{podiumSlogan}}</view>
<!-- Podium: top 3 (only when there are at least 3 entries) -->
<view class="podium" wx:if="{{rankedList.length >= 3}}">
<!-- 2nd -->
@@ -51,7 +54,7 @@
</view>
<text class="podium-name">{{rankedList[1].name}}</text>
<text class="podium-dur">{{rankedList[1].durationText}}</text>
<view class="podium-base podium-base--second"><text class="podium-base__label">{{rankedList[1].sessions}}</text></view>
<view class="podium-base podium-base--second"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[1].subDate}}</text><text class="podium-base__time">{{rankedList[1].subTime}}</text></block><text wx:else>{{rankedList[1].subText}}</text></view></view>
</view>
<!-- 1st -->
<view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;">
@@ -62,7 +65,7 @@
</view>
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
<view class="podium-base podium-base--first"><text class="podium-base__label">{{rankedList[0].sessions}}</text></view>
<view class="podium-base podium-base--first"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[0].subDate}}</text><text class="podium-base__time">{{rankedList[0].subTime}}</text></block><text wx:else>{{rankedList[0].subText}}</text></view></view>
</view>
<!-- 3rd -->
<view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;">
@@ -72,7 +75,7 @@
</view>
<text class="podium-name">{{rankedList[2].name}}</text>
<text class="podium-dur">{{rankedList[2].durationText}}</text>
<view class="podium-base podium-base--third"><text class="podium-base__label">{{rankedList[2].sessions}}</text></view>
<view class="podium-base podium-base--third"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[2].subDate}}</text><text class="podium-base__time">{{rankedList[2].subTime}}</text></block><text wx:else>{{rankedList[2].subText}}</text></view></view>
</view>
</view>
@@ -91,7 +94,7 @@
<view class="avatar avatar--sm {{item.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{item.avatarUrl || icons.peopleFill}}" mode="{{item.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info">
<text class="rank-name">{{item.name}}</text>
<text class="rank-sessions">{{item.sessions}}</text>
<text class="rank-sessions">{{item.subText}}</text>
</view>
<text class="rank-dur">{{item.durationText}}</text>
</view>
@@ -104,7 +107,7 @@
<view class="avatar avatar--sm my-bar__avatar {{myEntry.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{myEntry.avatarUrl || icons.peopleFill}}" mode="{{myEntry.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info">
<text class="rank-name">我 ({{myEntry.name}})</text>
<text class="rank-sessions">{{myEntry.sessions}}</text>
<text class="rank-sessions">{{myEntry.subText}}</text>
</view>
<text class="rank-dur">{{myEntry.durationText}}</text>
</view>
+41 -6
View File
@@ -69,6 +69,23 @@
opacity: 0.6;
}
/* 耐力榜领奖台顶部文案:哲理注脚,主色呼应 + 金句卡片质感 */
.podium-slogan {
position: relative;
margin: 8rpx 32rpx 4rpx;
padding: 22rpx 28rpx 24rpx;
text-align: center;
font-size: 24rpx;
line-height: 1.65;
color: var(--text);
font-weight: 500;
font-style: italic;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.03));
border: 1rpx solid rgba(var(--primary-rgb), 0.16);
border-radius: 18rpx;
box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.10);
}
/* ---- podium (top 3) ---- */
.podium {
display: flex;
@@ -194,14 +211,32 @@
stepped base heights (the podium itself is bottom-aligned via flex-end). */
.podium-base__label {
position: absolute;
left: 0;
right: 0;
bottom: 10rpx;
left: 4rpx;
right: 4rpx;
bottom: 6rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 22rpx;
font-weight: 700;
color: var(--text);
line-height: 1;
line-height: 1.15;
}
/* 耐力榜副标签(单次日期)在领奖台底座上拆成「日期 + 时间」两行。
缩小字号 + 抬高最矮的第 3 名底座(48→64rpx),三阶统一为 96/80/64,
让两行文字在各级底座内都垂直居中、不贴顶(含微信字体缩放余量)。 */
.podium-base__date {
font-size: 18rpx;
line-height: 1.15;
}
.podium-base__time {
font-size: 16rpx;
line-height: 1.1;
opacity: 0.85;
margin-top: 2rpx;
}
.podium-dur {
@@ -232,12 +267,12 @@
box-shadow: 0 6rpx 18rpx rgba(250, 140, 22, 0.30), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
}
.podium-base--second {
height: 64rpx;
height: 80rpx;
background: linear-gradient(180deg, rgba(201, 205, 212, 0.50), rgba(140, 140, 140, 0.16));
box-shadow: 0 6rpx 18rpx rgba(140, 140, 140, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
}
.podium-base--third {
height: 48rpx;
height: 64rpx;
background: linear-gradient(180deg, rgba(232, 160, 106, 0.50), rgba(212, 107, 8, 0.16));
box-shadow: 0 6rpx 18rpx rgba(212, 107, 8, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
}
+55 -55
View File
@@ -3,7 +3,12 @@ const util = require('../../utils/util')
const themeMod = require('../../utils/theme')
const iconsMod = require('../../utils/icons')
const config = require('../../config')
const { getBadgeProgress, getBadgeCelebration } = require('../../utils/badge-state')
const {
getBadgePositions,
getBadgeSegments,
getBadgeDisplayLabel,
getBadgeCelebration
} = require('../../utils/badge-state')
Page({
data: {
@@ -17,15 +22,19 @@ Page({
maxDurationText: '',
totalDays: 0,
trainingBadges: [],
badgeProgress: 0,
badgeSegments: [],
nextBadgeHint: '',
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthRecords: [],
historyList: [],
listMode: 'month',
contentMode: 'records',
trendMode: 'week',
trendData: [],
trendSummary: {},
bestTrendData: [],
bestTrendSummary: {},
showDayDetail: false,
dayDetail: {},
icons: iconsMod.build()
@@ -56,10 +65,19 @@ Page({
},
refresh() {
storage.validateStreak()
const streak = storage.validateStreak()
const stats = storage.getTotalStats()
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
const records = (storage.getRecordsByMonth(monthKey) || []).map(r => ({ ...r, durationText: util.formatTime(r.duration) }))
// Decorate each record with a display string for circuit (循环) sessions.
const withText = (r) => {
const base = { ...r, durationText: util.formatTime(r.duration) }
if (r.mode === 'circuit') {
const rest = r.restPerSet ? ` · 休${r.restPerSet}s` : ''
base.circuitText = `循环 ${r.sets}组×${r.holdPerSet}s${rest}`
}
return base
}
const records = (storage.getRecordsByMonth(monthKey) || []).map(withText)
// Each month is already sorted newest-first in storage. To get top 30 most recent,
// walk months in reverse chrono order and concat — O(months*30) instead of O(n log n).
@@ -68,27 +86,24 @@ Page({
const historyList = []
for (const mk of monthKeys) {
for (const r of byMonth[mk]) {
historyList.push({ ...r, durationText: util.formatTime(r.duration) })
historyList.push(withText(r))
if (historyList.length >= 30) break
}
if (historyList.length >= 30) break
}
// 累计训练天数勋章(里程碑进度条):统计口径=累计去重训练日(可中断)。
// 节点等距排列(间距一致、视觉整齐),填充在区间内线性插值,
// 解锁时填充正好抵达该节点,满级时填充满格。
// 节点等距排列;每个连接段独立填充,绿色铺满即解锁下一枚勋章。
const totalDays = stats.totalDays
const badgeDefs = config.trainingDayBadges
const n = badgeDefs.length
const TRACK_PAD = 4 // 首尾留白,保证首尾节点/标签不溢出
const segWidth = n > 1 ? (100 - TRACK_PAD * 2) / (n - 1) : 0
const posOf = (i) => TRACK_PAD + i * segWidth
const badgePositions = getBadgePositions(badgeDefs)
const trainingBadges = badgeDefs.map((b, i) => ({
...b,
unlocked: totalDays >= b.days,
pos: Math.round(posOf(i) * 10) / 10
pos: badgePositions[i],
label: getBadgeDisplayLabel(b, totalDays >= b.days)
}))
const badgeProgress = getBadgeProgress(badgeDefs, totalDays)
const badgeSegments = getBadgeSegments(badgeDefs, totalDays)
// 单枚解锁庆祝反馈:对比持久化"已见解锁集合",只庆祝新增勋章。
// 首次运行(无存储)仅初始化集合,避免老用户一打开就被历史勋章刷屏。
@@ -101,6 +116,9 @@ Page({
: `还差 ${badgeDefs[nextIdx].days - totalDays} 天解锁「${badgeDefs[nextIdx].name}`
const theme = themeMod.getCurrentTheme()
const trendMode = this.data.trendMode || 'week'
const trendData = this._computeTrend(trendMode)
const bestTrendData = this._computeBestTrend(trendMode)
this.setData({
theme,
icons: iconsMod.build(theme),
@@ -111,13 +129,16 @@ Page({
maxDurationText: util.formatDuration(stats.maxDuration),
totalDays,
trainingBadges,
badgeProgress,
badgeSegments,
nextBadgeHint,
monthRecords: records,
historyList,
listMode: this.data.listMode || 'month',
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
trendData: this._computeTrend(this.data.trendMode || 'week'),
trendData,
trendSummary: { ...util.getTrendSummary(trendData, trendMode), streakCount: Number(streak.count) || 0 },
bestTrendData,
bestTrendSummary: util.getTrendSummary(bestTrendData, trendMode),
loading: false
})
},
@@ -153,10 +174,24 @@ Page({
this.refresh()
},
onToggleContentMode(e) {
const mode = e.currentTarget.dataset.mode
if (!['records', 'trend'].includes(mode) || mode === this.data.contentMode) return
this.setData({ contentMode: mode })
},
onToggleTrendMode(e) {
const mode = e.currentTarget.dataset.mode
if (!mode || mode === this.data.trendMode) return
this.setData({ trendMode: mode, trendData: this._computeTrend(mode) })
const trendData = this._computeTrend(mode)
const bestTrendData = this._computeBestTrend(mode)
this.setData({
trendMode: mode,
trendData,
trendSummary: { ...util.getTrendSummary(trendData, mode), streakCount: Number(storage.getStreak().count) || 0 },
bestTrendData,
bestTrendSummary: util.getTrendSummary(bestTrendData, mode)
})
},
/**
@@ -166,46 +201,11 @@ Page({
* 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。
*/
_computeTrend(mode) {
const allRecords = Object.values(storage.getRecords()).flat()
const today = new Date()
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const pad = (n) => String(n).padStart(2, '0')
return util.getTrendData(storage.getRecords(), mode)
},
if (mode === 'month') {
const data = []
for (let i = 5; i >= 0; i--) {
const d = new Date(today.getFullYear(), today.getMonth() - i, 1)
const monthKey = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
const monthRecords = storage.getRecordsByMonth(monthKey) || []
const total = monthRecords.reduce((s, r) => s + (Number(r.duration) || 0), 0)
data.push({
label: `${d.getMonth() + 1}`,
value: total,
valueText: total > 0 ? util.formatDuration(total) : '',
highlight: i === 0
})
}
return data
}
// default: week (近 7 天)
const data = []
for (let i = 6; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const dateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const total = allRecords
.filter((r) => util.dateOnly(r.date) === dateStr)
.reduce((s, r) => s + (Number(r.duration) || 0), 0)
const isToday = i === 0
data.push({
label: isToday ? '今天' : weekdays[d.getDay()],
value: total,
valueText: total > 0 ? util.formatDuration(total) : '',
highlight: isToday
})
}
return data
_computeBestTrend(mode) {
return util.getBestTrendData(storage.getRecords(), mode)
},
onDayTap(e) {
+48 -8
View File
@@ -24,7 +24,7 @@
</view>
<!-- 统计卡片 (有数据时才显示) -->
<ui-card variant="in" hidden="{{totalSessions === 0}}">
<ui-card variant="in" index="0" hidden="{{totalSessions === 0}}">
<view class="stats">
<view class="stat-item">
<image class="stat-icon" src="{{icons.rankFill}}" mode="aspectFit"></image>
@@ -45,7 +45,7 @@
</ui-card>
<!-- 训练勋章 (里程碑进度条) -->
<ui-card variant="in" hidden="{{totalSessions === 0}}">
<ui-card variant="in" index="1" hidden="{{totalSessions === 0}}">
<view class="badge-section">
<view class="badge-header">
<view class="badge-title-wrap">
@@ -59,7 +59,10 @@
<text class="badge-status-next" wx:if="{{nextBadgeHint}}">{{nextBadgeHint}}</text>
</view>
<view class="badge-track">
<view class="badge-fill" style="width: {{badgeProgress}}%;"></view>
<view class="badge-segment" wx:for="{{badgeSegments}}" wx:key="index"
style="left: {{item.left}}%; width: {{item.width}}%;">
<view class="badge-segment-fill" style="width: {{item.progress}}%;"></view>
</view>
<view class="badge-node {{item.unlocked ? 'unlocked' : ''}} {{item.justUnlocked ? 'celebrate' : ''}}"
wx:for="{{trainingBadges}}" wx:key="days"
style="left: {{item.pos}}%;"
@@ -72,13 +75,20 @@
<view class="badge-labels">
<text class="badge-label {{item.unlocked ? 'unlocked' : ''}}"
wx:for="{{trainingBadges}}" wx:key="days"
style="left: {{item.pos}}%;">{{item.unlocked ? item.name : item.days}}</text>
style="left: {{item.pos}}%;">{{item.label}}</text>
</view>
</view>
</ui-card>
<!-- 内容分区:日历与记录共享月份状态,趋势按需展示,减少默认页长度。 -->
<view class="records-content-tabs" hidden="{{totalSessions === 0}}">
<view class="records-content-tab {{contentMode === 'records' ? 'active' : ''}}" data-mode="records" bindtap="onToggleContentMode">记录日历</view>
<view class="records-content-tab {{contentMode === 'trend' ? 'active' : ''}}" data-mode="trend" bindtap="onToggleContentMode">趋势</view>
</view>
<!-- 训练趋势 -->
<ui-card variant="in" hidden="{{totalSessions === 0}}">
<block wx:if="{{contentMode === 'trend'}}">
<ui-card variant="in" index="2" hidden="{{totalSessions === 0}}">
<view class="trend-section">
<view class="trend-header">
<view class="trend-title-wrap">
@@ -90,12 +100,41 @@
<view class="rec-seg-item {{trendMode === 'month' ? 'active' : ''}}" data-mode="month" bindtap="onToggleTrendMode">近6月</view>
</view>
</view>
<view class="trend-summary">
<view class="trend-summary-item">
<text class="trend-summary-value">{{trendSummary.totalText}}</text>
<text class="trend-summary-label">累计时长</text>
</view>
<view class="trend-summary-item">
<text class="trend-summary-value">{{trendSummary.activeCount}}</text>
<text class="trend-summary-label">{{trendSummary.activeLabel}}</text>
</view>
<view class="trend-summary-item">
<text class="trend-summary-value">{{trendSummary.streakCount}}</text>
<text class="trend-summary-label">连续打卡</text>
</view>
</view>
<trend-chart chart-data="{{trendData}}"></trend-chart>
<view class="trend-highlight">
<image class="trend-highlight-icon" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<text class="trend-highlight-title">{{trendSummary.peakTitle}}</text>
<text class="trend-highlight-text">{{trendSummary.peakText}}</text>
</view>
<view class="best-trend-header">
<view class="trend-title-wrap">
<image class="history-title-icon" src="{{icons.crownFill}}" mode="aspectFit"></image>
<text class="best-trend-title">个人最佳趋势</text>
</view>
<text class="best-trend-best">{{bestTrendSummary.peakText}}</text>
</view>
<trend-chart chart-data="{{bestTrendData}}" variant="peak"></trend-chart>
</view>
</ui-card>
</block>
<!-- 日历热力图 -->
<ui-card variant="in" hidden="{{totalSessions === 0}}">
<block wx:if="{{contentMode === 'records'}}">
<ui-card variant="in" index="2" hidden="{{totalSessions === 0}}">
<view class="calendar-section">
<view class="month-picker">
<view class="month-arrow" bindtap="onPrevMonth">
@@ -120,7 +159,7 @@
</ui-card>
<!-- 历史记录 -->
<ui-card variant="in" hidden="{{totalSessions === 0}}">
<ui-card variant="in" index="3" hidden="{{totalSessions === 0}}">
<view class="history-section">
<view class="history-header">
<view class="history-title-wrap">
@@ -151,7 +190,7 @@
></image>
<view class="history-left">
<text class="history-date">{{item.date}}</text>
<text class="history-plan">{{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}}</text>
<text class="history-plan">{{item.mode === 'circuit' ? item.circuitText : (item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天')}}</text>
</view>
<view class="history-right">
<text class="history-duration">{{item.durationText}}</text>
@@ -164,6 +203,7 @@
</view>
</view>
</ui-card>
</block>
</view>
<!-- /hidden wrapper -->
+104 -2
View File
@@ -101,11 +101,14 @@
border-radius: 2rpx;
}
.badge-fill {
.badge-segment {
position: absolute;
left: 0;
top: 0;
height: 100%;
}
.badge-segment-fill {
height: 100%;
background: var(--primary);
border-radius: 2rpx;
transition: width 0.6s ease;
@@ -196,6 +199,32 @@
opacity: 1;
}
/* ---- records / trend top-level switch ---- */
.records-content-tabs {
display: flex;
margin: 8rpx 0 20rpx;
padding: 6rpx;
background: var(--bg-soft);
border-radius: 999rpx;
}
.records-content-tab {
flex: 1;
padding: 14rpx 0;
border-radius: 999rpx;
color: var(--text-secondary);
font-size: 26rpx;
text-align: center;
transition: background 0.25s ease, color 0.25s ease;
}
.records-content-tab.active {
background: var(--card-bg);
color: var(--primary);
font-weight: 700;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
}
/* ---- calendar ---- */
.calendar-section {
padding: 0;
@@ -264,6 +293,79 @@
margin-bottom: 0;
}
.trend-summary {
display: flex;
margin: 16rpx 0 4rpx;
padding: 16rpx 0;
background: rgba(var(--primary-rgb), 0.06);
border-radius: 16rpx;
}
.trend-summary-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
min-width: 0;
}
.trend-summary-value {
color: var(--primary);
font-size: 28rpx;
font-weight: 800;
}
.trend-summary-label {
margin-top: 4rpx;
color: var(--text-secondary);
font-size: 20rpx;
}
.trend-highlight {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 18rpx;
padding: 14rpx 16rpx;
background: var(--bg-soft);
border-radius: 14rpx;
}
.trend-highlight-icon {
width: 28rpx;
height: 28rpx;
}
.trend-highlight-title {
color: var(--text-secondary);
font-size: 22rpx;
}
.trend-highlight-text {
color: var(--text);
font-size: 22rpx;
font-weight: 600;
}
.best-trend-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 28rpx;
}
.best-trend-title {
color: var(--text);
font-size: 26rpx;
font-weight: 700;
}
.best-trend-best {
color: var(--primary);
font-size: 20rpx;
font-weight: 600;
}
/* ---- history ---- */
.history-section {
padding: 0;
+7 -7
View File
@@ -1,6 +1,6 @@
<view class="container page-enter" style="{{themeStyle}}">
<!-- 个人资料 -->
<ui-card variant="in">
<ui-card variant="in" index="0">
<view class="section-header">
<image class="section-icon" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<text class="section-title">个人资料</text>
@@ -34,7 +34,7 @@
</ui-card>
<!-- 配色方案 -->
<ui-card variant="in">
<ui-card variant="in" index="1">
<view class="section-header">
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
<text class="section-title">配色方案</text>
@@ -56,7 +56,7 @@
</ui-card>
<!-- 深色模式 -->
<ui-card variant="in">
<ui-card variant="in" index="2">
<view class="section-header">
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
<text class="section-title">深色模式</text>
@@ -76,7 +76,7 @@
</ui-card>
<!-- 训练计划选择 -->
<ui-card variant="in">
<ui-card variant="in" index="3">
<view class="section-header">
<image class="section-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<text class="section-title">训练计划</text>
@@ -107,7 +107,7 @@
</ui-card>
<!-- 训练偏好 -->
<ui-card variant="in">
<ui-card variant="in" index="4">
<view class="section-header">
<image class="section-icon" src="{{icons.settingsFill}}" mode="aspectFit"></image>
<text class="section-title">训练偏好</text>
@@ -159,7 +159,7 @@
</ui-card>
<!-- 数据管理 -->
<ui-card variant="in">
<ui-card variant="in" index="5">
<view class="section-header">
<image class="section-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
<text class="section-title">数据管理</text>
@@ -175,7 +175,7 @@
</ui-card>
<!-- 关于 -->
<ui-card variant="in">
<ui-card variant="in" index="6">
<view class="section-header">
<image class="section-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
<text class="section-title">关于</text>
+352 -7
View File
@@ -1,4 +1,5 @@
const Timer = require('../../utils/timer')
const CircuitTimer = require('../../utils/circuitTimer')
const storage = require('../../utils/storage')
const planMod = require('../../utils/plan')
const themeMod = require('../../utils/theme')
@@ -22,6 +23,27 @@ Page({
todayTarget: 0,
planDay: 1,
isFreeMode: false,
// --- Circuit (循环训练) mode fields ---
isCircuitMode: false,
circuitSets: 4,
circuitHold: 30,
circuitRest: 15,
circuitSessions: 3,
currentSet: 0,
totalSets: 0,
phase: 'idle', // idle | working | resting | done
phaseRemaining: 0,
phaseElapsed: 0,
workTotal: 0, // effective held seconds (excludes rest)
isResting: false,
phaseTip: '', // 阶段切换提示文本(如"最后一组!"/"休息 15 秒")
// 顶部循环进度面板:组进度胶囊点阵
circuitDots: [], // [1..sets],仅用于 wx:for 渲染,组数过多时不渲染
circuitShowDots: true, // 组数 > DOTS_MAX 时退化为线性进度条
circuitTotalWork: 0, // sets × holdPerSet,面板里作为"目标秒数"展示
dotsDone: 0, // 已完成组数
dotsActive: 0, // 正在进行的组序号(休息时为 0)
dotsNext: 0, // 休息时即将开始的组序号(工作时为 0)
celebrationLevel: 'normal', // normal / first / milestone / record
celebrationMessage: '',
confettiPieces: [],
@@ -40,9 +62,13 @@ Page({
onLoad(options) {
themeMod.applyThemeToPage(this)
this._init(options)
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)
// 按模式取词条:循环模式要 restStart/nextSet/lastSet,单组模式要 goal,
// 各自不载对方的,省掉无谓的云函数调用与下载。
const s = storage.getSettings()
if (s.voiceGuide !== false) voice.preload()
if (s.voiceGuide !== false) {
voice.preload(this.data.isCircuitMode ? voice.CIRCUIT_KEYS : voice.HOLD_KEYS)
}
// countUp: 数字从 0 滚到目标秒数
const target = this.data.duration
if (this._countUpTask) this._countUpTask.cancel()
@@ -59,9 +85,21 @@ Page({
themeMod.applyThemeToPage(this)
const theme = themeMod.getCurrentTheme()
this.setData({ theme, icons: iconsMod.build(theme) })
// Coming back from the background (WeChat chat, phone call, home screen)
// clears the app-wide keep-awake flag, so re-arm it if a session is still
// in flight — otherwise the second half of the training silently loses
// every voice/haptic cue again.
if (this.data.isRunning) this._keepScreenOn(true)
},
_init(options) {
this._initOptions = options || {}
// Circuit (循环训练) mode: build the work/rest FSM and bail out of the
// single-hold init below. All controls / completion modal / save path are shared.
if (this._initOptions.circuit) {
this._initCircuit(this._initOptions)
return
}
let target
let planDay = 1
let isFreeMode = false
@@ -125,6 +163,250 @@ Page({
})
},
/**
* Initialize a circuit (循环) training session. Reads the persisted config
* (utils/storage.getCircuitConfig) but lets URL params override so the home
* page can jump straight in with the last-saved settings.
*/
_initCircuit(options) {
if (this._timer) {
this._timer.stop()
this._timer = null
}
// Reset per-session state (mirrors the single-hold path)
this._halfwaySignaled = false
this._last30Signaled = false
this._last10Signaled = false
this._lastMinuteAt = 0
this._lastCountdown = 0
this._saving = false
this._lastTipIndex = -1
this._voiceBusyUntil = 0
this._clearVibrateTimers()
const cfg = storage.getCircuitConfig()
const hold = this._int(options.hold, cfg.holdPerSet, 5, 600)
const sets = this._int(options.sets, cfg.sets, 1, 50)
const rest = this._int(options.rest, cfg.restPerSet, 0, 600)
this._circuitCfg = {
holdPerSet: hold,
sets,
restPerSet: rest,
sessionsPerWeek: cfg.sessionsPerWeek
}
this._timer = new CircuitTimer({
holdPerSet: hold,
sets,
restPerSet: rest,
onTick: (t) => {
// 组进度点阵三态。休息阶段 CircuitTimer 的 setIndex 仍指向"刚做完
// 的那一组",所以 resting 时 setIndex 组算已完成、下一组标为 next。
const resting = t.phase === 'resting'
this.setData({
currentSet: t.setIndex,
totalSets: t.sets,
phase: t.phase,
phaseRemaining: t.phaseRemaining,
phaseElapsed: t.phaseElapsed,
workTotal: t.workTotal,
isResting: resting,
dotsDone: resting ? t.setIndex : Math.max(0, t.setIndex - 1),
dotsActive: resting ? 0 : t.setIndex,
dotsNext: resting ? t.setIndex + 1 : 0,
// Reuse the single-hold display fields so progress-ring / completion
// number keep working: the ring shows the CURRENT set's countdown.
remaining: t.phaseRemaining,
elapsed: t.workTotal,
duration: t.phase === 'working' ? hold : rest
})
this._circuitCue(t)
},
onPhaseChange: (p) => this._circuitPhase(p),
onComplete: () => this._onCircuitComplete()
})
// 点阵超过 DOTS_MAX 个就挤成头发丝了,退化成一条线性进度条。
const DOTS_MAX = 15
const dots = []
if (sets <= DOTS_MAX) {
for (let i = 1; i <= sets; i++) dots.push(i)
}
this.setData({
isCircuitMode: true,
isFreeMode: false,
planDay: 0,
circuitSets: sets,
circuitHold: hold,
circuitRest: rest,
circuitSessions: this._circuitCfg.sessionsPerWeek,
circuitDots: dots,
circuitShowDots: sets <= DOTS_MAX,
circuitTotalWork: sets * hold,
dotsDone: 0,
dotsActive: 0,
dotsNext: 0,
currentSet: 0,
totalSets: sets,
phase: 'idle',
phaseRemaining: hold,
phaseElapsed: 0,
workTotal: 0,
isResting: false,
phaseTip: '',
duration: hold,
remaining: hold,
todayTarget: hold,
goalReached: false,
elapsed: 0,
status: 'idle',
isRunning: false,
isPaused: false,
isCompleted: false,
showCompletion: false
})
},
_int(v, fallback, min, max) {
const n = parseInt(v)
if (!Number.isFinite(n)) return fallback
return Math.max(min, Math.min(max, n))
},
/**
* Fire a haptic buzz, tracked so pause/stop/unload can cancel pending ones.
* Mirrors the vibrate helper inside _remind().
*/
_vibrateOnce(n, ms) {
const s = storage.getSettings()
if (s.vibrate === false) return
const intensity = s.vibrateIntensity || 'heavy'
const doVibrate = () => {
const fail = (err) => console.warn('[vibrate] failed:', err.errMsg || err)
if (intensity === 'light') wx.vibrateShort({ type: 'light', fail })
else if (intensity === 'medium') wx.vibrateShort({ type: 'medium', fail })
else wx.vibrateLong({ fail })
}
try {
for (let i = 0; i < n; i++) {
const id = setTimeout(() => {
doVibrate()
const idx = this._vibrateTimers.indexOf(id)
if (idx > -1) this._vibrateTimers.splice(idx, 1)
}, i * (ms + 30))
this._vibrateTimers.push(id)
}
} catch (e) {}
},
/**
* Per-tick circuit cues. Mirrors _remind() for the single-hold mode, but the
* yardstick is the WHOLE session's effective held time (sets × holdPerSet),
* NOT the current set — otherwise a 4-set circuit would announce "已完成一半啦"
* four times (once per set), which is both noisy and semantically wrong.
*
* Only evaluated while `working`: workTotal is frozen during rest, so the
* thresholds can never re-fire mid-break.
*/
_circuitCue(t) {
if (t.phase !== 'working') return
const total = this._circuitCfg.sets * this._circuitCfg.holdPerSet
const done = t.workTotal
const left = total - done
const s = storage.getSettings()
const voiceOn = s.voiceGuide !== false
// Same duration gates as _remind(), so short circuits (e.g. 3 sets × 5s)
// don't fire "最后30秒" on the very first tick. `<=` rather than `===`
// survives a skipped second when the app returns from background.
if (total >= 10 && !this._halfwaySignaled && done >= Math.floor(total / 2)) {
this._halfwaySignaled = true
if (voiceOn) this._playProgressVoice('halfway')
this._vibrateOnce(2, 150)
}
if (total > 60 && left <= 30 && !this._last30Signaled) {
this._last30Signaled = true
if (voiceOn) this._playProgressVoice('last30')
}
if (total > 20 && left <= 10 && !this._last10Signaled) {
this._last10Signaled = true
if (voiceOn) this._playProgressVoice('last10')
}
// Countdown buzz over the last 5s of the FINAL set (the real finish line).
if (t.setIndex >= this._circuitCfg.sets && t.phaseRemaining <= 5 && t.phaseRemaining > 0 && t.phaseRemaining !== this._lastCountdown) {
this._lastCountdown = t.phaseRemaining
this._vibrateOnce(1, 100)
}
},
/**
* Play a whole-session progress prompt (halfway / last30 / last10) and claim
* a short priority window afterwards.
*
* Why: the halfway mark is `sets × hold / 2`, which for an EVEN set count
* lands exactly on the final second of set sets/2 — i.e. the same tick as the
* rest transition. voice.play() drops any in-flight clip when a new one
* starts, so without this the phase line would cut "已完成一半啦" off after
* one syllable. Progress prompts win; the phase line is the expendable one.
*/
_playProgressVoice(key) {
this._voiceBusyUntil = Date.now() + 2600 // ~ one spoken line
voice.play(key)
},
/**
* Fired once on each work/rest transition: haptic + on-screen phaseTip +
* a voice line (restStart / nextSet / lastSet — number-free, so 3 fixed
* clips cover any set count).
*
* `chatty` gate: a spoken line takes ~2s, so on short sets (<10s each) the
* prompts would overlap into a stream of chatter and step on the training
* rhythm. Below that we keep only the "last set" cue (the one moment worth
* interrupting for) and let haptics + text carry the rest.
*/
_circuitPhase(p) {
const s = storage.getSettings()
const chatty = this._circuitCfg.holdPerSet >= 10
// Yield the mic if a progress prompt is still speaking (see _playProgressVoice).
const voiceOn = s.voiceGuide !== false && Date.now() >= (this._voiceBusyUntil || 0)
if (p.phase === 'working') {
if (p.setIndex === 1) {
// No voice here: onStart already plays 'start' at the same instant.
this.setData({ phaseTip: '开始第一组' })
} else if (p.isLastSet) {
this.setData({ phaseTip: '最后一组,冲刺!' })
if (voiceOn) voice.play('lastSet')
this._vibrateOnce(2, 150)
} else {
this.setData({ phaseTip: '第 ' + p.setIndex + ' 组' })
if (voiceOn && chatty) voice.play('nextSet')
this._vibrateOnce(1, 150)
}
} else if (p.phase === 'resting') {
this.setData({ phaseTip: '休息 ' + this._circuitCfg.restPerSet + ' 秒' })
// Skip on very short breaks — the clip would still be talking when the
// next set starts, colliding with nextSet/lastSet.
if (voiceOn && chatty && this._circuitCfg.restPerSet >= 5) voice.play('restStart')
this._vibrateOnce(1, 100)
}
},
/**
* All sets completed naturally. workTotal already equals sets*holdPerSet.
* Reuse the shared completion/save path so celebration + streak + record
* all behave identically to a normal session.
*/
_onCircuitComplete() {
this._clearVibrateTimers()
const s = storage.getSettings()
if (s.voiceGuide !== false) voice.play('complete')
this._saveAndShowCompletion(this.data.workTotal)
},
/**
* Rotate the posture cue in the hint area every config.tipInterval seconds.
* Only runs while still under the target (after goalReached the hint
@@ -233,6 +515,31 @@ Page({
this._vibrateTimers = []
},
/**
* Keep the screen awake while training.
*
* Why this matters here: during a plank the user's hands are on the floor —
* nobody taps the screen, so the system's idle timer (usually 60s) fires
* mid-session. Once the screen locks, the mini program is backgrounded and
* JS timers get throttled/suspended: the elapsed count still recovers
* (Timer/CircuitTimer reconcile against Date.now on resume), but every
* real-time cue in between — halfway / last30 / last10 voice, set-change
* and rest prompts, haptics — is simply lost.
*
* Scope caveat: wx.setKeepScreenOn is APP-wide, not page-scoped, and only
* resets when the user exits the mini program. So onUnload MUST turn it
* back off, otherwise the screen stays lit while they browse records or
* the leaderboard afterwards.
*
* fail is swallowed on purpose: some Android ROMs ignore this under battery
* saver, and a "failed to keep screen on" toast would only confuse the user
* — the training itself is unaffected. Supported since base library 1.4.0.
*/
_keepScreenOn(on) {
if (typeof wx.setKeepScreenOn !== 'function') return
wx.setKeepScreenOn({ keepScreenOn: !!on, fail() {} })
},
/**
* Detect which celebration level to show. Called from _saveAndShowCompletion
* (runs in finishTraining, BEFORE saveRecord), so stats reflect pre-save state.
@@ -244,7 +551,14 @@ Page({
_detectCelebrationLevel(elapsed) {
const stats = storage.getTotalStats()
const streak = storage.getStreak()
const nextStreak = (streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1
// 本函数运行在保存记录之前(pre-save state)。updateStreak 对「同一天再次训练」
// 会直接 return、不递增连胜数;所以若 streak.lastDate 已是今天,nextStreak 应取
// 当前 count 而非 count+1,否则庆祝文案会比真实连胜多 1 天。
const todayOnly = storage.getToday().substring(0, 10)
const lastIsToday = !!(streak.lastDate && streak.lastDate.substring(0, 10) === todayOnly)
const nextStreak = lastIsToday
? (streak.count || 1)
: ((streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1)
if (stats.totalSessions === 0) {
return { level: 'first', message: '第一次训练!坚持就是胜利' }
@@ -283,6 +597,9 @@ Page({
onUnload() {
voice.stop()
voice.destroy()
// Mandatory: the flag is app-wide, so leaving it on would keep the screen
// lit across every other page until the user quits the mini program.
this._keepScreenOn(false)
this._clearVibrateTimers()
if (this._timer) {
this._timer.stop()
@@ -301,10 +618,18 @@ Page({
onStart() {
if (this.data.isPaused) {
this._timer.resume()
// Re-assert: if the user backgrounded the app while paused, WeChat has
// already cleared the flag for us.
this._keepScreenOn(true)
this.setData({ isRunning: true, isPaused: false, status: 'running' })
return
}
if (this.data.isRunning) return
// Screen stays awake from here until onUnload. Deliberately NOT turned
// off on pause (users pause to adjust posture — a screen blackout mid-set
// is worse than a few seconds of extra backlight) nor on the completion
// modal (they're reading their result).
this._keepScreenOn(true)
// 取消可能还在滚的 countUp,确保 remaining = duration
if (this._countUpTask) {
this._countUpTask.cancel()
@@ -393,12 +718,23 @@ Page({
// Save record + streak
const today = storage.getToday()
storage.saveRecord({
const record = {
date: today,
duration: elapsed,
planId: storage.getSettings().planId,
// Circuit records use a special planId so getPlanDay() never counts
// them toward the daily-plan progress; leaderboard/stats aggregate by
// `duration`, so a circuit's effective held seconds count normally.
planId: this.data.isCircuitMode ? 'circuit' : storage.getSettings().planId,
day: this.data.planDay
})
}
if (this.data.isCircuitMode && this._circuitCfg) {
record.mode = 'circuit'
record.sets = this._circuitCfg.sets
record.holdPerSet = this._circuitCfg.holdPerSet
record.restPerSet = this._circuitCfg.restPerSet
record.sessionsPerWeek = this._circuitCfg.sessionsPerWeek
}
storage.saveRecord(record)
storage.updateStreak(today)
// Flag for the index page's "训练完成" highlight
@@ -420,7 +756,9 @@ Page({
completionElapsed: 0,
completionActualElapsed: elapsed,
completionTarget: this.data.duration,
completionOvertime: Math.max(0, elapsed - this.data.duration),
// Circuit mode has no "overtime" concept (each set is a fixed target)
// and `duration` here is just the last set's length, so hide it.
completionOvertime: this.data.isCircuitMode ? 0 : Math.max(0, elapsed - this.data.duration),
completionStreak: finalStreak,
completionLevel: level,
completionMessage: message
@@ -468,6 +806,13 @@ Page({
this._completionCountUp.cancel()
this._completionCountUp = null
}
// Circuit mode: rebuild the FSM (start() only resets from idle, and a
// finished/paused timer is no longer idle, so a fresh init is the clean
// reset; _initCircuit also closes the completion modal).
if (this.data.isCircuitMode) {
this._initCircuit(this._initOptions)
return
}
// Reset per-session state so the next run fires reminders/saves
// correctly (previously these stayed set, muting cues on the 2nd run).
this._saving = false
+41 -3
View File
@@ -15,6 +15,43 @@
<text class="achievement-text">{{celebrationMessage}}</text>
</view>
<!-- 循环训练进度面板:组点阵 + 第 X/Y 组 + 阶段徽章 + 累计秒数。
层次分工:这里管「第几组」(宏观),中间的进度环管「本组还剩几秒」(微观)。 -->
<view
class="circuit-bar {{isResting ? 'is-rest' : ''}} {{status === 'paused' ? 'is-paused' : ''}}"
wx:if="{{isCircuitMode && status !== 'completed'}}"
>
<!-- 组进度点阵:已完成 / 进行中 / 下一组 三态 -->
<view class="circuit-dots" wx:if="{{circuitShowDots}}">
<view
wx:for="{{circuitDots}}"
wx:key="*this"
class="circuit-dot {{item <= dotsDone ? 'is-done' : ''}} {{item === dotsActive ? 'is-active' : ''}} {{item === dotsNext ? 'is-next' : ''}}"
></view>
</view>
<!-- 组数过多(>15)时退化为线性进度条,避免点阵挤成头发丝 -->
<view class="circuit-track" wx:else>
<view class="circuit-track-fill" style="width: {{dotsDone * 100 / totalSets}}%"></view>
</view>
<view class="circuit-meta">
<view class="circuit-count">
<text class="circuit-count-num">{{status === 'idle' ? totalSets : currentSet}}</text>
<text class="circuit-count-total" wx:if="{{status !== 'idle'}}">/{{totalSets}}</text>
<text class="circuit-count-unit">组</text>
</view>
<view class="circuit-phase circuit-phase--{{status === 'paused' ? 'pause' : (status === 'idle' ? 'idle' : (isResting ? 'rest' : 'work'))}}">
<view class="circuit-phase-dot"></view>
<text class="circuit-phase-text">{{status === 'paused' ? '已暂停' : (status === 'idle' ? '准备开始' : (isResting ? '休息中' : '撑住'))}}</text>
</view>
</view>
<text class="circuit-sub">
<block wx:if="{{status === 'idle'}}">每组 {{circuitHold}} 秒{{circuitRest > 0 ? ' · 休息 ' + circuitRest + ' 秒' : ''}}</block>
<block wx:else>已撑 {{workTotal}} 秒 · 目标 {{circuitTotalWork}} 秒</block>
</text>
</view>
<!-- 呼吸引导环 + 进度条 -->
<view class="ring-wrapper">
<view class="breathe-ring breathe-ring-1 {{status === 'running' ? 'fast' : ''}}"
@@ -34,7 +71,7 @@
status="{{status}}"
primaryColor="{{theme.primary}}"
primaryLightColor="{{theme.primaryLight}}"
trackColor="{{elapsed >= duration ? '#00B578' : (isDark ? '#3A3A40' : '#EEEEEE')}}"
trackColor="{{isCircuitMode ? '#EEEEEE' : (elapsed >= duration ? '#00B578' : '#EEEEEE')}}"
></progress-ring>
<!-- 完成庆祝粒子 -->
@@ -53,7 +90,8 @@
<view class="hint-row" wx:if="{{status === 'idle'}}">
<image class="hint-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
<text class="hint-text">
<block wx:if="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
<block wx:if="{{isCircuitMode}}">循环训练 · 共需撑 {{circuitTotalWork}} 秒</block>
<block wx:elif="{{isFreeMode}}">自由训练 · 目标 {{duration}} 秒</block>
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
</text>
</view>
@@ -63,7 +101,7 @@
</view>
<view class="hint-row" wx:elif="{{status === 'running'}}">
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
<text class="hint-text running">{{hintTip}}</text>
<text class="hint-text running">{{isCircuitMode ? phaseTip : hintTip}}</text>
</view>
<view class="hint-row" wx:elif="{{status === 'paused'}}">
<image class="hint-icon" src="{{icons.notificationForbidFill}}" mode="aspectFit"></image>
+215
View File
@@ -9,6 +9,221 @@
box-sizing: border-box;
}
/* ---- 循环训练进度面板 ----
注意:整块不设固定 height,靠 padding 撑开。真机大字体档下文字会被微信
放大,固定高度 + overflow 会把数字裁掉(项目里踩过这个坑)。 */
.circuit-bar {
/* 抬一层:呼吸光晕(.breathe-ring, z-index:0)在 DOM 里排在本面板之后,
不抬层会把橙色晕染盖到卡片上。 */
position: relative;
z-index: 1;
width: 620rpx;
max-width: 86%;
box-sizing: border-box;
padding: 26rpx 32rpx 22rpx;
margin-bottom: 12rpx;
background: var(--card-bg);
border: 1rpx solid rgba(var(--primary-rgb), 0.14);
border-radius: 28rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
transition: border-color 0.35s ease, box-shadow 0.35s ease;
animation: circuitBarIn 0.45s cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
/* 休息态整块降温:边框转成 success 绿,和"撑住"的主色态形成对比 */
.circuit-bar.is-rest {
border-color: rgba(var(--success-rgb), 0.32);
box-shadow: 0 6rpx 20rpx rgba(var(--success-rgb), 0.10);
}
@keyframes circuitBarIn {
0% { opacity: 0; transform: translateY(-16rpx); }
100% { opacity: 1; transform: translateY(0); }
}
/* --- 组进度点阵 ---
整行铺满面板内宽,每段按组数严格等分(分段进度条式)。 */
.circuit-dots {
display: flex;
align-items: center;
width: 100%;
/* 锁住行高:当前组比其它段高 6rpx,不锁行高会在 idle→running 时整块跳一下 */
min-height: 18rpx;
gap: 8rpx;
margin-bottom: 22rpx;
}
/* flex:1 1 0 + min-width:0 → 每段等分剩余宽度,与内容无关。
不设 max-width:一旦封顶,组数少时点阵就只占左边一截(此前的问题)。
当前组不再靠"加宽"强调(那会破坏等分),改为增高+发光,宽度恒等分。 */
.circuit-dot {
flex: 1 1 0;
min-width: 0;
height: 12rpx;
border-radius: 6rpx;
background: var(--bg-soft);
transition: height 0.3s ease, background 0.3s ease, box-shadow 0.3s ease;
}
.circuit-dot.is-done {
background: var(--primary);
opacity: 0.85;
}
/* 当前组:增高 + 渐变 + 发光呼吸,一眼锁定进度位置(宽度仍等分) */
.circuit-dot.is-active {
height: 18rpx;
border-radius: 9rpx;
opacity: 1;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.55);
animation: circuitDotPulse 1.5s ease-in-out infinite;
}
/* 休息时高亮"下一组",给用户一个即将开始的预告焦点 */
.circuit-dot.is-next {
height: 18rpx;
border-radius: 9rpx;
background: rgba(var(--success-rgb), 0.45);
animation: circuitDotPulse 1.8s ease-in-out infinite;
}
@keyframes circuitDotPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* 暂停时冻结所有呼吸动画,静止本身就是"已暂停"的信号 */
.circuit-bar.is-paused .circuit-dot {
animation: none;
}
/* --- 组数过多时的线性降级条 --- */
.circuit-track {
height: 12rpx;
border-radius: 6rpx;
background: var(--bg-soft);
overflow: hidden;
margin-bottom: 22rpx;
}
.circuit-track-fill {
height: 100%;
border-radius: 6rpx;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
transition: width 0.4s ease;
}
/* --- 主信息行:大数字 + 阶段徽章 --- */
.circuit-meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.circuit-count {
display: flex;
align-items: baseline;
}
.circuit-count-num {
font-size: 56rpx;
font-weight: 800;
color: var(--text);
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
}
.circuit-count-total {
font-size: 32rpx;
font-weight: 600;
color: var(--text-secondary);
line-height: 1;
margin-left: 2rpx;
}
.circuit-count-unit {
font-size: 26rpx;
color: var(--text-secondary);
margin-left: 8rpx;
}
/* --- 阶段徽章 --- */
.circuit-phase {
display: flex;
align-items: center;
gap: 10rpx;
padding: 12rpx 24rpx;
border-radius: 30rpx;
transition: background 0.3s ease;
}
/* 状态指示灯用 CSS 画而非 icon:项目里的 SVG 图标颜色烤死在 data URI 里,
CSS 改不动;这里需要跟随状态换色,纯 CSS 圆点是唯一干净的做法。 */
.circuit-phase-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
flex-shrink: 0;
}
.circuit-phase-text {
font-size: 26rpx;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
/* 工作态:主色渐变实心 + 白灯白字 */
.circuit-phase--work {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.32);
}
.circuit-phase--work .circuit-phase-dot {
background: #FFFFFF;
animation: phaseDotBlink 1.2s ease-in-out infinite;
}
.circuit-phase--work .circuit-phase-text { color: #FFFFFF; }
/* 休息态:绿色淡底,视觉降温 */
.circuit-phase--rest {
background: rgba(var(--success-rgb), 0.14);
}
.circuit-phase--rest .circuit-phase-dot {
background: var(--success);
animation: phaseDotBlink 1.8s ease-in-out infinite;
}
.circuit-phase--rest .circuit-phase-text { color: var(--success); }
/* 待机态 / 暂停态:中性灰,不抢视觉 */
.circuit-phase--idle,
.circuit-phase--pause {
background: var(--bg-soft);
}
.circuit-phase--idle .circuit-phase-dot,
.circuit-phase--pause .circuit-phase-dot {
background: var(--text-secondary);
}
.circuit-phase--idle .circuit-phase-text,
.circuit-phase--pause .circuit-phase-text {
color: var(--text-secondary);
}
@keyframes phaseDotBlink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* --- 副信息行 --- */
.circuit-sub {
display: block;
margin-top: 14rpx;
font-size: 24rpx;
color: var(--text-secondary);
line-height: 1.3;
}
/* ---- ring wrapper ---- */
.ring-wrapper {
position: relative;
+160
View File
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>首页按钮:圆角 vs 直角 对照预览</title>
<style>
:root {
--primary: #FF6B35;
--primary-light: #FF8C5A;
--primary-bg: #FFF3ED;
--primary-rgb: 255,107,53;
--text-secondary: #999999;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
background: #f4f5f7;
color: #222;
padding: 32px 16px 60px;
display: flex;
flex-direction: column;
align-items: center;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
.sub { font-size: 13px; color: #888; margin-bottom: 28px; text-align: center; line-height: 1.6; }
.compare {
display: flex;
gap: 28px;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
}
.col {
background: #fff;
border-radius: 18px;
padding: 24px 22px 28px;
width: 320px;
box-shadow: 0 6px 24px rgba(0,0,0,0.06);
}
.col h2 {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.col .tag {
display: inline-block;
font-size: 12px;
padding: 2px 10px;
border-radius: 999px;
margin-bottom: 20px;
}
.col.round .tag { background: var(--primary-bg); color: var(--primary); }
.col.square .tag { background: #eee; color: #666; }
/* ---- button replica (matches ui-btn) ---- */
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
font-weight: 600;
line-height: 1;
width: 100%;
margin-bottom: 14px;
cursor: default;
user-select: none;
}
.btn .dot { width: 18px; height: 18px; border-radius: 50%; background: currentColor; opacity: .85; }
.btn--xl { height: 54px; font-size: 18px; }
.btn--md { height: 40px; font-size: 15px; }
.btn--lg { height: 48px; font-size: 17px; }
.btn--primary {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: #fff;
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
}
.btn--ghost {
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary);
border: 1px solid rgba(var(--primary-rgb), 0.22);
}
/* the only difference that matters: */
.round .btn { border-radius: 24px; }
.square .btn { border-radius: 8px; } /* ≈16rpx */
/* two secondary buttons side by side */
.btn-row { display: flex; gap: 8px; }
.btn-row .btn { margin-bottom: 14px; }
.context-note {
margin-top: 16px;
font-size: 12px;
color: #aaa;
line-height: 1.5;
border-top: 1px dashed #eee;
padding-top: 12px;
}
.verdict {
margin-top: 30px;
font-size: 13px;
color: #666;
text-align: center;
max-width: 660px;
line-height: 1.7;
}
.verdict b { color: var(--primary); }
</style>
</head>
<body>
<h1>首页功能按钮 · 圆角 vs 直角</h1>
<p class="sub">左侧是小程序现状(圆角 48rpx),右侧是本次改后的效果(直角 0)。<br/>同一套按钮、同一套配色,方便你直接对比「好不好看」。</p>
<div class="compare">
<!-- 圆角现状 -->
<div class="col round">
<h2>现状 · 圆角</h2>
<span class="tag">border-radius: 48rpx</span>
<div class="btn btn--primary btn--xl"><span class="dot"></span>开始训练</div>
<div class="btn-row">
<div class="btn btn--ghost btn--xl"><span class="dot"></span>自由训练</div>
<div class="btn btn--ghost btn--md"><span class="dot"></span>循环训练</div>
</div>
<hr style="border:none;border-top:1px solid #f0f0f0;margin:18px 0 16px;">
<div class="btn btn--primary btn--lg">开始自由训练</div>
<div class="btn btn--primary btn--lg">保存并开始</div>
<div class="context-note">页面其余元素仍是圆角:卡片 24rpx、进度条 6rpx、目标环是正圆。</div>
</div>
<!-- 直角改后 -->
<div class="col square">
<h2>改后 · 小圆角</h2>
<span class="tag">border-radius: 16rpx</span>
<div class="btn btn--primary btn--xl"><span class="dot"></span>开始训练</div>
<div class="btn-row">
<div class="btn btn--ghost btn--xl"><span class="dot"></span>自由训练</div>
<div class="btn btn--ghost btn--md"><span class="dot"></span>循环训练</div>
</div>
<hr style="border:none;border-top:1px solid #f0f0f0;margin:18px 0 16px;">
<div class="btn btn--primary btn--lg">开始自由训练</div>
<div class="btn btn--primary btn--lg">保存并开始</div>
<div class="context-note">仅首页这几个按钮变直角,全局组件与其他页面不受影响。</div>
</div>
</div>
<p class="verdict">
小圆角(16rpx)比 10rpx 更圆润一点,更接近卡片的 24rpx 圆角语言,但仍不是全胶囊,保留了清爽感。<br/>
已应用到首页 5 个按钮(仅首页、不动全局组件)。若还想微调,往上可贴近卡片的 24rpx、往下回到 10rpx,说一声即可。
</p>
</body>
</html>
+40 -2
View File
@@ -1,9 +1,23 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { getBadgeProgress, getBadgeCelebration } = require('../utils/badge-state')
const {
getBadgeProgress,
getBadgePositions,
getBadgeSegments,
getBadgeDisplayLabel,
getBadgeCelebration
} = require('../utils/badge-state')
const badges = [{ days: 3 }, { days: 7 }, { days: 14 }]
const badges = [
{ days: 3, name: '抖登' },
{ days: 7, name: '中登' },
{ days: 14, name: '老登' },
{ days: 30, name: '神登' },
{ days: 60, name: '上古神登' },
{ days: 80, name: '洪荒神登' },
{ days: 100, name: '究极登祖' }
]
test('badge progress remains zero before the first milestone', () => {
assert.equal(getBadgeProgress(badges, 1), 0)
@@ -11,6 +25,30 @@ test('badge progress remains zero before the first milestone', () => {
assert.equal(getBadgeProgress(badges, 3), 4)
})
test('milestones use equal spacing and overall fill stays aligned', () => {
assert.deepEqual(getBadgePositions(badges), [4, 19.3, 34.7, 50, 65.3, 80.7, 96])
assert.equal(getBadgeProgress(badges, 7), 19.3)
assert.equal(getBadgeProgress(badges, 60), 65.3)
assert.equal(getBadgeProgress(badges, 100), 100)
})
test('only the active badge connection is partially green', () => {
assert.deepEqual(getBadgeSegments(badges, 16), [
{ left: 4, width: 15.3, progress: 100 },
{ left: 19.3, width: 15.4, progress: 100 },
{ left: 34.7, width: 15.3, progress: 12.5 },
{ left: 50, width: 15.3, progress: 0 },
{ left: 65.3, width: 15.4, progress: 0 },
{ left: 80.7, width: 15.3, progress: 0 }
])
})
test('equal spacing keeps full unlocked badge names readable', () => {
assert.equal(getBadgeDisplayLabel(badges[3], true), '神登')
assert.equal(getBadgeDisplayLabel(badges[4], true), '上古神登')
assert.equal(getBadgeDisplayLabel(badges[6], false), '100天')
})
test('badge celebration keeps prior milestones seen after records are deleted', () => {
const afterDeletion = getBadgeCelebration([3], [])
assert.deepEqual(afterDeletion, { newly: [], seen: [3] })
+78
View File
@@ -0,0 +1,78 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { getTrendData, getBestTrendData, getTrendSummary, getBarPercent } = require('../utils/util')
test('weekly trend combines multiple sessions from the same day', () => {
const records = {
'2026-07': [
{ date: '2026-07-29 08:00:00', duration: 30 },
{ date: '2026-07-29 20:00:00', duration: 45 },
{ date: '2026-07-23 08:00:00', duration: 60 }
]
}
assert.deepEqual(getTrendData(records, 'week', new Date(2026, 6, 29)), [
{ label: '四', value: 60, valueText: '01:00', highlight: false },
{ label: '五', value: 0, valueText: '', highlight: false },
{ label: '六', value: 0, valueText: '', highlight: false },
{ label: '日', value: 0, valueText: '', highlight: false },
{ label: '一', value: 0, valueText: '', highlight: false },
{ label: '二', value: 0, valueText: '', highlight: false },
{ label: '今天', value: 75, valueText: '01:15', highlight: true }
])
})
test('monthly trend spans a year boundary from a single record snapshot', () => {
const records = {
'2025-08': [{ date: '2025-08-31 08:00:00', duration: 60 }],
'2025-12': [{ date: '2025-12-01 08:00:00', duration: 120 }],
'2026-01': [{ date: '2026-01-02 08:00:00', duration: 3600 }]
}
assert.deepEqual(getTrendData(records, 'month', new Date(2026, 0, 15)), [
{ label: '8月', value: 60, valueText: '01:00', highlight: false },
{ label: '9月', value: 0, valueText: '', highlight: false },
{ label: '10月', value: 0, valueText: '', highlight: false },
{ label: '11月', value: 0, valueText: '', highlight: false },
{ label: '12月', value: 120, valueText: '02:00', highlight: false },
{ label: '1月', value: 3600, valueText: '60m', highlight: true }
])
})
test('bar height remains proportional for very short sessions', () => {
assert.equal(getBarPercent(0, 120), 0)
assert.ok(getBarPercent(1, 120) < 1)
assert.equal(getBarPercent(120, 120), 100)
})
test('trend summary reports total, active periods, and the highest period', () => {
const summary = getTrendSummary([
{ label: '一', value: 60 },
{ label: '二', value: 0 },
{ label: '今天', value: 75 }
], 'week')
assert.deepEqual(summary, {
totalText: '02:15',
activeCount: 2,
activeLabel: '训练天数',
peakTitle: '最高训练日',
peakText: '今天 · 01:15'
})
})
test('personal best trend keeps the longest single session in each period', () => {
const records = {
'2026-07': [
{ date: '2026-07-29 08:00:00', duration: 30 },
{ date: '2026-07-29 20:00:00', duration: 45 },
{ date: '2026-07-23 08:00:00', duration: 60 }
]
}
const data = getBestTrendData(records, 'week', new Date(2026, 6, 29))
assert.equal(data[0].value, 60)
assert.equal(data[6].value, 45)
assert.equal(data[6].valueText, '00:45')
})
+46 -3
View File
@@ -1,5 +1,15 @@
const TRACK_PAD = 4
const roundPosition = (value) => Math.round(value * 10) / 10
const getBadgePositions = (badges) => {
const defs = Array.isArray(badges) ? badges : []
if (defs.length === 0) return []
if (defs.length === 1) return [TRACK_PAD]
const segmentWidth = (100 - TRACK_PAD * 2) / (defs.length - 1)
return defs.map((_, index) => roundPosition(TRACK_PAD + index * segmentWidth))
}
const getBadgeProgress = (badges, totalDays) => {
const defs = Array.isArray(badges) ? badges : []
if (defs.length === 0) return 0
@@ -8,12 +18,39 @@ const getBadgeProgress = (badges, totalDays) => {
if (days < defs[0].days) return 0
if (defs.length === 1 || days >= defs[defs.length - 1].days) return 100
const segmentWidth = (100 - TRACK_PAD * 2) / (defs.length - 1)
const positions = getBadgePositions(defs)
let index = 0
while (index < defs.length - 1 && days >= defs[index + 1].days) index++
const ratio = (days - defs[index].days) / (defs[index + 1].days - defs[index].days)
return Math.round((TRACK_PAD + index * segmentWidth + ratio * segmentWidth) * 10) / 10
return roundPosition(positions[index] + ratio * (positions[index + 1] - positions[index]))
}
const getBadgeSegments = (badges, totalDays) => {
const defs = Array.isArray(badges) ? badges : []
const positions = getBadgePositions(defs)
const days = Math.max(0, Number(totalDays) || 0)
return defs.slice(0, -1).map((badge, index) => {
const nextBadge = defs[index + 1]
const segmentDays = nextBadge.days - badge.days
const progress = days <= badge.days
? 0
: days >= nextBadge.days
? 100
: roundPosition((days - badge.days) / segmentDays * 100)
return {
left: positions[index],
width: roundPosition(positions[index + 1] - positions[index]),
progress
}
})
}
const getBadgeDisplayLabel = (badge, unlocked) => {
if (!badge) return ''
return unlocked && badge.name ? badge.name : `${badge.days}`
}
const getBadgeCelebration = (previous, unlockedDays) => {
@@ -25,4 +62,10 @@ const getBadgeCelebration = (previous, unlockedDays) => {
return { newly, seen: Array.from(new Set(previous.concat(unlocked))) }
}
module.exports = { getBadgeProgress, getBadgeCelebration }
module.exports = {
getBadgeProgress,
getBadgePositions,
getBadgeSegments,
getBadgeDisplayLabel,
getBadgeCelebration
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Circuit (循环) training timer — a finite state machine driving
* work / rest intervals across multiple sets.
*
* idle → working(set 1) → resting → working(set 2) → resting → … → done
*
* Distinct from utils/timer.js (which counts a single continuous hold up to
* a goal and then keeps going until the user stops). A circuit instead has
* discrete sets with rest gaps, so it needs its own FSM.
*
* `workTotal` (effective held seconds, EXCLUDING rest) is what we persist as
* the record's `duration` — same meaning as Timer.stop()'s return value — so
* the leaderboard / stats aggregation needs zero changes.
*
* Mirrors utils/timer.js: 1s interval driven by Date.now() (drift-free),
* plus a wx.onAppShow hook so the UI snaps to the real elapsed time when the
* mini-program returns from background (WeChat freezes setInterval there).
*/
class CircuitTimer {
constructor(options = {}) {
this.holdPerSet = Math.max(1, parseInt(options.holdPerSet) || 30)
this.sets = Math.max(1, parseInt(options.sets) || 1)
this.restPerSet = Math.max(0, parseInt(options.restPerSet) || 0)
this.onTick = options.onTick || (() => {})
this.onPhaseChange = options.onPhaseChange || (() => {})
this.onComplete = options.onComplete || (() => {})
this._intervalId = null
this._running = false
this._paused = false
this._appShowBound = false
this.reset()
this._onAppShow = () => {
if (this._running && !this._paused) this._tick()
}
}
reset() {
this.phase = 'idle' // idle | working | resting | done
this.setIndex = 0 // 1-based current set (0 before start)
this.phaseRemaining = this.holdPerSet
this.phaseElapsed = 0
this.workTotal = 0 // effective held seconds (excludes rest)
this._completedSets = 0
this._phaseStart = 0
}
start() {
if (this.phase === 'idle') {
this.phase = 'working'
this.setIndex = 1
this.phaseRemaining = this.holdPerSet
this.phaseElapsed = 0
this._completedSets = 0
this._emitPhaseChange()
}
this._running = true
this._paused = false
this._phaseStart = Date.now() - this.phaseElapsed * 1000
this._tick()
this._intervalId = setInterval(() => this._tick(), 1000)
this._bindAppShow()
}
pause() {
if (!this._running || this._paused) return
this._paused = true
clearInterval(this._intervalId)
this._intervalId = null
}
resume() {
if (!this._running || !this._paused) return
this._paused = false
this._phaseStart = Date.now() - this.phaseElapsed * 1000
this._tick()
this._intervalId = setInterval(() => this._tick(), 1000)
}
stop() {
this._running = false
this._paused = false
clearInterval(this._intervalId)
this._intervalId = null
this._unbindAppShow()
// Effective held seconds — same role as Timer.stop()'s `elapsed`.
return this.workTotal
}
_tick() {
if (!this._running || this._paused) return
const cap = this.phase === 'working' ? this.holdPerSet : this.restPerSet
const elapsed = Math.floor((Date.now() - this._phaseStart) / 1000)
this.phaseElapsed = elapsed
this.phaseRemaining = Math.max(0, cap - elapsed)
if (this.phase === 'working') {
// Total held seconds = fully-completed sets + current set progress.
this.workTotal = this._completedSets * this.holdPerSet + elapsed
}
this._emitTick()
if (this.phaseRemaining <= 0) {
this._advance()
}
}
_advance() {
if (this.phase === 'working') {
this._completedSets += 1
if (this._completedSets >= this.sets) {
// All sets done.
this.phase = 'done'
this.setIndex = this.sets
this._running = false
clearInterval(this._intervalId)
this._intervalId = null
this._unbindAppShow()
this.onComplete({
workTotal: this.workTotal,
sets: this.sets,
holdPerSet: this.holdPerSet
})
return
}
// Move to rest (if any) or straight into the next set.
if (this.restPerSet > 0) {
this.phase = 'resting'
} else {
this.phase = 'working'
this.setIndex += 1
}
this.phaseElapsed = 0
this.phaseRemaining = this.phase === 'resting' ? this.restPerSet : this.holdPerSet
this._phaseStart = Date.now()
this._emitPhaseChange()
this._emitTick()
return
}
if (this.phase === 'resting') {
this.phase = 'working'
this.setIndex += 1
this.phaseElapsed = 0
this.phaseRemaining = this.holdPerSet
this._phaseStart = Date.now()
this._emitPhaseChange()
this._emitTick()
}
}
_emitTick() {
this.onTick({
phase: this.phase,
setIndex: this.setIndex,
sets: this.sets,
phaseRemaining: this.phaseRemaining,
phaseElapsed: this.phaseElapsed,
holdPerSet: this.holdPerSet,
restPerSet: this.restPerSet,
workTotal: this.workTotal
})
}
_emitPhaseChange() {
this.onPhaseChange({
phase: this.phase,
setIndex: this.setIndex,
sets: this.sets,
isLastSet: this.setIndex >= this.sets
})
}
_bindAppShow() {
if (this._appShowBound || typeof wx.onAppShow !== 'function') return
try {
wx.onAppShow(this._onAppShow)
this._appShowBound = true
} catch (e) {}
}
_unbindAppShow() {
if (!this._appShowBound || typeof wx.offAppShow !== 'function') return
try {
wx.offAppShow(this._onAppShow)
} catch (e) {}
this._appShowBound = false
}
}
module.exports = CircuitTimer
+3
View File
@@ -43,6 +43,9 @@ const PATHS = {
pauseFill: '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>',
time: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" fill-rule="evenodd"/>',
timeFill: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>',
// Loop / repeat (circuit training entry)
repeat: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',
repeatFill: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',
// Markers & targets
target: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" fill-rule="evenodd"/><circle cx="12" cy="12" r="5" fill-rule="evenodd"/><circle cx="12" cy="12" r="2"/>',
targetFill: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>',
+50
View File
@@ -6,6 +6,7 @@ const SETTINGS_KEY = 'user_settings'
const STREAK_KEY = 'current_streak'
const CUSTOM_PLANS_KEY = 'custom_plans'
const PROFILE_KEY = 'user_profile'
const CIRCUIT_CONFIG_KEY = 'circuit_config'
let _idSeq = 0
@@ -230,6 +231,53 @@ const saveProfile = (profile) => {
const getStreak = () => wx.getStorageSync(STREAK_KEY) || { count: 0, lastDate: '' }
/**
* Circuit (循环) training configuration — persisted locally (device-only,
* NOT cloud-synced; it's a per-device training preference, not account
* data, so no cloud.pushAll()). This is the "持久化" the simplified
* circuit-train feature needs: the user's { 每组时长 / 组数 / 休息 / 每周次数 }
* survives restarts and pre-fills the config panel next time.
*
* Shape: {
* holdPerSet: number // seconds held per set (>=5)
* sets: number // sets per session (>=1)
* restPerSet: number // rest seconds between sets (>=0; 0 = none)
* sessionsPerWeek: number // weekly target (>=1) — DISPLAY ONLY in the
* simplified version, no progress tracking yet
* }
* Any missing / out-of-range field falls back to a sensible default, so a
* corrupt or partial config never crashes the timer.
*/
const DEFAULT_CIRCUIT_CONFIG = { holdPerSet: 30, sets: 4, restPerSet: 15, sessionsPerWeek: 3 }
const _clampInt = (v, min, max, fallback) => {
const n = parseInt(v)
if (!Number.isFinite(n)) return fallback
return Math.max(min, Math.min(max, n))
}
const getCircuitConfig = () => {
const raw = wx.getStorageSync(CIRCUIT_CONFIG_KEY)
if (!raw || typeof raw !== 'object') return { ...DEFAULT_CIRCUIT_CONFIG }
return {
holdPerSet: _clampInt(raw.holdPerSet, 5, 600, DEFAULT_CIRCUIT_CONFIG.holdPerSet),
sets: _clampInt(raw.sets, 1, 50, DEFAULT_CIRCUIT_CONFIG.sets),
restPerSet: _clampInt(raw.restPerSet, 0, 600, DEFAULT_CIRCUIT_CONFIG.restPerSet),
sessionsPerWeek: _clampInt(raw.sessionsPerWeek, 1, 14, DEFAULT_CIRCUIT_CONFIG.sessionsPerWeek)
}
}
const saveCircuitConfig = (cfg) => {
if (!cfg || typeof cfg !== 'object') return
const clean = {
holdPerSet: _clampInt(cfg.holdPerSet, 5, 600, DEFAULT_CIRCUIT_CONFIG.holdPerSet),
sets: _clampInt(cfg.sets, 1, 50, DEFAULT_CIRCUIT_CONFIG.sets),
restPerSet: _clampInt(cfg.restPerSet, 0, 600, DEFAULT_CIRCUIT_CONFIG.restPerSet),
sessionsPerWeek: _clampInt(cfg.sessionsPerWeek, 1, 14, DEFAULT_CIRCUIT_CONFIG.sessionsPerWeek)
}
wx.setStorageSync(CIRCUIT_CONFIG_KEY, clean)
}
/**
* User-customized training plans, keyed by planId (e.g. 'beginner').
* Each value is a full plan object built by `utils/plan.generatePlanDays`
@@ -474,6 +522,8 @@ module.exports = {
getProfile,
saveProfile,
getStreak,
getCircuitConfig,
saveCircuitConfig,
validateStreak,
recomputeStreak,
updateStreak,
+124
View File
@@ -54,6 +54,126 @@ const formatDate = (date) => {
/** Strip the time portion of a formatDate string → "YYYY-MM-DD". */
const _dateOnly = (s) => (typeof s === 'string' && s.length >= 10) ? s.substring(0, 10) : ''
const _normalizeDuration = (value) => Math.max(0, Math.floor(Number(value) || 0))
const _formatTrendDuration = (seconds) => {
const duration = _normalizeDuration(seconds)
if (duration >= 3600) return `${Math.floor(duration / 60)}m`
return formatTime(duration)
}
/** Build daily or monthly duration totals from one training-record snapshot. */
const getTrendData = (recordGroups, mode, now = new Date()) => {
const dateTotals = {}
const groups = recordGroups && typeof recordGroups === 'object' ? recordGroups : {}
Object.values(groups).forEach((records) => {
if (!Array.isArray(records)) return
records.forEach((record) => {
const date = _dateOnly(record && record.date)
if (!date) return
dateTotals[date] = (dateTotals[date] || 0) + _normalizeDuration(record.duration)
})
})
const today = new Date(now)
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const pad = (n) => String(n).padStart(2, '0')
if (mode === 'month') {
const monthTotals = {}
Object.keys(dateTotals).forEach((date) => {
const month = date.substring(0, 7)
monthTotals[month] = (monthTotals[month] || 0) + dateTotals[date]
})
return Array.from({ length: 6 }, (_, index) => {
const offset = 5 - index
const date = new Date(today.getFullYear(), today.getMonth() - offset, 1)
const monthKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
const value = monthTotals[monthKey] || 0
return {
label: `${date.getMonth() + 1}`,
value,
valueText: value > 0 ? _formatTrendDuration(value) : '',
highlight: offset === 0
}
})
}
return Array.from({ length: 7 }, (_, index) => {
const offset = 6 - index
const date = new Date(today)
date.setDate(date.getDate() - offset)
const dateKey = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
const value = dateTotals[dateKey] || 0
return {
label: offset === 0 ? '今天' : weekdays[date.getDay()],
value,
valueText: value > 0 ? _formatTrendDuration(value) : '',
highlight: offset === 0
}
})
}
const getBestTrendData = (recordGroups, mode, now = new Date()) => {
const peaks = {}
const groups = recordGroups && typeof recordGroups === 'object' ? recordGroups : {}
Object.values(groups).forEach((records) => {
if (!Array.isArray(records)) return
records.forEach((record) => {
const date = _dateOnly(record && record.date)
if (!date) return
const key = mode === 'month' ? date.substring(0, 7) : date
peaks[key] = Math.max(peaks[key] || 0, _normalizeDuration(record.duration))
})
})
const today = new Date(now)
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const pad = (n) => String(n).padStart(2, '0')
const count = mode === 'month' ? 6 : 7
return Array.from({ length: count }, (_, index) => {
const offset = count - 1 - index
const date = mode === 'month'
? new Date(today.getFullYear(), today.getMonth() - offset, 1)
: new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset)
const key = mode === 'month'
? `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
const value = peaks[key] || 0
return {
label: mode === 'month' ? `${date.getMonth() + 1}` : offset === 0 ? '今天' : weekdays[date.getDay()],
value,
valueText: value > 0 ? _formatTrendDuration(value) : '',
highlight: offset === 0
}
})
}
const getBarPercent = (value, max) => {
const amount = _normalizeDuration(value)
const maximum = _normalizeDuration(max)
return maximum > 0 ? amount / maximum * 100 : 0
}
const getTrendSummary = (trendData, mode) => {
const items = Array.isArray(trendData) ? trendData : []
const total = items.reduce((sum, item) => sum + _normalizeDuration(item && item.value), 0)
const activeItems = items.filter(item => _normalizeDuration(item && item.value) > 0)
const peak = activeItems.reduce((current, item) => {
return !current || _normalizeDuration(item.value) > _normalizeDuration(current.value) ? item : current
}, null)
const isMonth = mode === 'month'
return {
totalText: _formatTrendDuration(total),
activeCount: activeItems.length,
activeLabel: isMonth ? '活跃月份' : '训练天数',
peakTitle: isMonth ? '最高训练月' : '最高训练日',
peakText: peak ? `${peak.label} · ${_formatTrendDuration(peak.value)}` : '暂无训练数据'
}
}
/**
* Convert a local file path (typically `wxfile://...` from chooseAvatar) to a
* base64 data URI. Used to make temporary file URLs survive the WeChat
@@ -101,5 +221,9 @@ module.exports = {
getMonthCalendar,
formatDate,
dateOnly: _dateOnly,
getTrendData,
getBestTrendData,
getTrendSummary,
getBarPercent,
fileToDataURI
}
+14 -3
View File
@@ -1,7 +1,8 @@
/**
* Voice prompt utility for the timer.
*
* 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that
* A fixed set of polished prompts lives in `cloudfunctions/tts` (PROMPTS
* below must stay in sync with the cloud function's copy). We call that
* cloud function with a key, get back an audio URL and permanent fileID,
* and play it via createInnerAudioContext.
*
@@ -25,9 +26,19 @@ const PROMPTS = {
last30: '最后30秒,保持呼吸,稳住姿势!',
last10: '最后10秒,再加把劲!',
goal: '目标达成,继续挑战!',
complete: '太棒了!今天的目标已完成,继续加油!'
complete: '太棒了!今天的目标已完成,继续加油!',
// --- 循环训练(circuit)阶段切换专用 ---
// 刻意不含组号数字,这样固定 3 条就能覆盖任意组数,无需按 N 合成 N 份音频。
restStart: '休息一下,调整呼吸',
nextSet: '下一组,准备开始',
lastSet: '最后一组,全力冲刺!'
}
// 单组(hold)与循环(circuit)各自需要的词条。preload 按模式取,避免
// 单组训练白白预载 3 条循环语音(每条一次云函数调用 + 一次下载)。
const HOLD_KEYS = ['start', 'halfway', 'last30', 'last10', 'goal', 'complete']
const CIRCUIT_KEYS = ['start', 'halfway', 'last30', 'last10', 'complete', 'restStart', 'nextSet', 'lastSet']
const FILEID_CACHE_KEY = 'tts_fileid_cache'
const LOCAL_CACHE_KEY = 'tts_local_cache' // { cacheKey: savedFilePath } - 持久本地文件,跨重启
@@ -238,4 +249,4 @@ const destroy = () => {
}
}
module.exports = { play, preload, stop, destroy, PROMPTS }
module.exports = { play, preload, stop, destroy, PROMPTS, HOLD_KEYS, CIRCUIT_KEYS }