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(静默降级不报错)
This commit is contained in:
@@ -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 断了不回收)。
|
||||
- 用户明确要求:本次只分析、不改代码。
|
||||
@@ -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)` 比 lastDate(timer.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 限)。两数字各管一摊。
|
||||
@@ -0,0 +1,188 @@
|
||||
# 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.js:saveRecord 支持 sets/holdPerSet/restPerSet/mode
|
||||
- pages/timer + 新 utils/circuitTimer.js:circuit 状态机与 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/mode;duration 仍=有效撑总秒(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 模式重建 FSM;completion 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(有 active,18rpx)时整块跳 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-1;records 页 0,1,2(trend 分支),2(records 分支,与 trend 互斥故同号),3;settings 页 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)` 脉冲首次真正生效。
|
||||
@@ -9,6 +9,8 @@
|
||||
- 加新图标或新彩色容器时,先想清楚底色:浅色底用 `*Fill`(主题色),彩色/主色底用 `*White` 变体。
|
||||
- 主按钮 `ui-btn--primary` 背景是橙渐变,`playFill`(橙) 放上去即隐形——须用 `playWhite`。ghost/outline 按钮底色浅或灰,用 `*Fill`/灰色变体即可。
|
||||
- `components/ui-btn` 图标尺寸 `1.3em`(随按钮字号自适应),不要回到 `1em` 以免显得过小。
|
||||
- **需要「随状态换色」的小装饰件,一律用纯 CSS 画,别用 icon**:图标色改不动,而状态灯/圆点要在 白/绿/灰 间切换。`timer` 页循环训练徽章的 `.circuit-phase-dot` 即是此解(14rpx CSS 圆点 + BEM 状态类控制 background)。
|
||||
- **`timer` 页新增顶部元素必须 `position:relative; z-index:1`**:`.breathe-ring`(呼吸光晕,`z-index:0`)在 DOM 里排在页面上方元素之后,同 stacking 上下文下会反盖上去,把主色光晕晕染到白卡片上。循环训练进度面板 `.circuit-bar` 踩过。
|
||||
- 深色模式有两套事实源,改配色前先统一:`theme.json` 的 dark 与 `app.wxss`/`utils/theme.js` 要一致(已统一到 `#131316` 系)。
|
||||
- **微信圆形头像/图片裁剪陷阱(用户基础库实测)**:本项目的圆形头像在用户机器上表现如下——
|
||||
- 单独给 `<image>` 加 `border-radius:50%`(无 overflow 父层)→ 圆框里露方图,失效。
|
||||
@@ -30,14 +32,50 @@
|
||||
|
||||
## 排行榜架构(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`;首次写自动建集合)。
|
||||
- **定时触发器**(`config.json` 的 `triggers`:`snapshotTimer` / `0 */4 * * * * *`,每 4 分钟)进入 `_rebuildSnapshots()`:扫描一次 `plank_data`(`_fetchLatestByOpenid`,走 updatedAt 索引+投影),复用 `_buildFromScan()` 算 day/month/year/endurance 四榜,各自写进 `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` 无需改逻辑(仅注释更新)。
|
||||
- **耐力榜(第 4 个 tab,2026-07-31 新增)**:全局单次最久。`_buildFromScan` 的 `endurance` 分支遍历每人全部 records 取 `max(duration)` 并记下该条 `r.date` 作"产生时间"→ `bestDate` 字段靠 `publicEntry` 的 `...publicData` 展开透传进快照/响应(无需改 data.js)。客户端 `periods` 加 `{key:'endurance',label:'耐力',maxRank:10}`;行/领奖台/my-bar 的副标签统一改用 `subText`(耐力=格式化日期 `YYYY-MM-DD HH:MM`,其余=`N次`)。本人未进 top10 时由既有 my-bar(`myEntry.rank > rankedList.length`)在底部显示其最佳成绩+日期——依赖快照存 top500、`findMyEntry` 在完整 500 条里定位本人,故即便排 200 也能被找到。**改云函数后须重新部署**(定时器随部署注册 `endurance`)。
|
||||
|
||||
- **TTS 语音词条是「两份白名单」,必须同步**:`utils/voice.js` 的 `PROMPTS` 与 `cloudfunctions/tts/index.js` 的 `PROMPTS` 各存一份。客户端有、云端没有 → 云函数返回 `Unknown prompt key`,`_fetchUrl` 得 `success:false` 后返回 null,`play()` 静默 return(**不报错、不弹 toast**,只是不出声,极难察觉)。加词条后**必须重新部署 tts 云函数**。词条设计上要**避免包含数字/变量**(如"第3组"),否则得按 N 合成 N 份音频;用"下一组,准备开始"这类通用句即可覆盖任意组数。
|
||||
- **`voice.play` 会截断前一条语音**(`_playSeq` 机制:新 play 令旧的作废)。凡是可能同一 tick 触发两条语音的地方,必须定优先级。已知必撞场景:circuit 的 halfway 触发点 = `sets×hold/2`,**偶数组时必然落在第 sets/2 组最后一秒**,与 rest 阶段切换同刻 → 用 `_playProgressVoice` 设 `_voiceBusyUntil = now+2600`,让阶段语音在窗口内让位。类似地,每组 <10 秒时不播过场语音(一句话约 2 秒,否则连珠炮)。
|
||||
- **circuit 的进度播报基准必须是整场(`sets × holdPerSet`),不能是每组**:否则 4 组会播 4 次"已完成一半啦"。判据用 `<=` 而非 `===`,容忍小程序后台回前台的跳秒补偿。只在 `phase==='working'` 求值——休息时 `workTotal` 冻结,天然不会重复触发。
|
||||
|
||||
- **`wx.setKeepScreenOn` 是「小程序级」不是「页面级」,且切后台会失效**(训练页已用,`pages/timer/timer.js` 的 `_keepScreenOn`):
|
||||
- 开了不关 → 用户离开训练页后,首页/记录/榜单全程常亮耗电,只有**退出小程序**才自动失效。故 `onUnload` **必须**显式 `keepScreenOn:false`。
|
||||
- 反过来,**切后台再回前台会被系统清掉**,所以 `onShow` 里要 `if (this.data.isRunning) 重新武装`,否则后半程又开始息屏丢语音/震动。
|
||||
- `fail` 必须静默(部分 Android ROM 省电模式拒绝该调用),低基础库用 `typeof wx.setKeepScreenOn !== 'function'` 降级。
|
||||
- 背景:息屏/后台后 JS 定时器被节流,`Timer`/`CircuitTimer` 靠 `Date.now` 补偿秒数不会错,但**实时提示(语音/震动)错过就没了**——平板支撑用户手撑地不碰屏,60 秒必息屏,所以常亮实际是在修 bug 而非锦上添花。
|
||||
|
||||
- **自定义组件宿主节点默认是 inline —— 页面侧给组件标签加 border/box-shadow/transform 会碎裂或静默失效**:本项目 `components/` 下 7 个组件**没有任何一个**写了 `:host { display: block }`(全局 grep `:host`/`display:block` 零匹配),组件 wxss 只给内部那个 view(如 `.ui-card`)写样式。宿主节点(页面 wxml 里的 `<ui-card>` 标签)因此是 inline 盒。
|
||||
- 页面 wxss **可以**命中宿主节点上的 class(style isolation 只隔离组件内部),所以样式确实生效了——但按 inline 盒规则渲染:
|
||||
- `border` → inline 盒被内部 block 打断成前/后两个**零宽行盒**,只在碎片上画左右边框(各 4rpx 贴合成一根竖线)→ 视觉是 block **左上角、左下角各一条竖条**,而不是包一圈。首页 `.just-completed`(训练完成 3 秒高亮)就是这个症状。
|
||||
- `transform: scale()` → **完全无效**(不适用于非替换 inline 元素),脉冲/缩放动画静默不生效。
|
||||
- `box-shadow` → 同样只画在零宽碎片上,等于看不见。
|
||||
- **诊断特征**:只要是"某个自定义组件标签上加了装饰样式,结果出现零散短线/动画不动",先怀疑宿主 inline,而不是怀疑选择器没命中。
|
||||
- **修法**:组件 wxss 加 `:host { display: block; }`(小程序支持 `:host`)。改前需回归各页面该组件的间距布局(inline→block 会吃掉行内空白、宽度变 100%);`ui-btn` 这类可能被当行内用的组件要单独评估。
|
||||
- **同源坑**:`ui-card.wxss` 的 `.ui-card.in:nth-child(1..4)` 交错入场延迟也是失效的——`.ui-card` 在组件内部永远是根下第 1 个子元素,恒匹配 `nth-child(1)`。跨组件边界做"第 N 个"选择器一律不可靠,要交错必须由页面传 index 或内联 `animation-delay`。
|
||||
- **已修(2026-08-03,仅 `ui-card`)**:加了 `:host { display:block; margin-bottom:24rpx; box-sizing:border-box }`,并把 `margin-bottom` 从内部 `.ui-card` **上提到宿主**——否则页面侧加的边框会把 24rpx 外边距一起圈进框里,框比卡片高一截。交错延迟改为 `index` 属性驱动的内联 `animation-delay`(`50 + min(index,3)*100` ms),14 处调用点全部显式传 `index`;`index` 默认 0,漏传也只是退回旧行为,零回归。
|
||||
- **其余 6 个组件宿主仍是 inline,不要盲目批量加 `:host{display:block}`**:`ui-btn` 在 `pages/timer/timer.wxml` 里靠 `custom-style="margin-right:28rpx"` 并排布局,改 block 会直接撑成整行。要改必须逐个回归。
|
||||
- **页面侧给组件标签做描边,优先用 `box-shadow: 0 0 0 Nrpx <color>` 而不是 `border`**:border 会改变宿主盒尺寸(撑大或在 border-box 下向内压窄内容),高亮出现/消失时布局跳动;box-shadow 不占布局空间。但宿主自身没有圆角,必须显式补上与内部卡片一致的 `border-radius`,否则描边是直角框套在圆角卡片外面。
|
||||
|
||||
## 约定
|
||||
- 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 key,remote 仅 HTTPS。
|
||||
|
||||
## 功能:循环训练 circuit(2026-08-03 落地,简化版 + 配置持久化)
|
||||
- 定位:**「自由训练」的循环形态**,不是新计划体系。不碰现有 `utils/plan.js` 的 hold 模式与每日计划进度。
|
||||
- 入口:首页「循环训练 · 分组练习」ghost 按钮 → 底部配置弹窗(stepper 调 每组时长/组数/休息/每周目标)→ `saveCircuitConfig` + 跳 `timer?circuit=1&hold=&sets=&rest=&sessions=`。
|
||||
- 配置持久化:`utils/storage.js` 的 `getCircuitConfig/saveCircuitConfig`(本地 `circuit_config` 键,含默认值+范围校验,**不进云同步**)。下次打开弹窗预填上次值。
|
||||
- 状态机:`utils/circuitTimer.js`(新)`CircuitTimer` 类,接口仿 `Timer`:`start/stop/pause/resume` + `onTick/onPhaseChange/onComplete`;`stop()` 返回 `workTotal`(有效撑总秒,不含休息)。
|
||||
- **关键不变量 1:`duration` 始终 = 有效撑总秒(组数×每组秒,不含休息)** → 排行榜/里程碑/统计零改动。
|
||||
- **关键不变量 2:循环记录 `planId` 用 `'circuit'`** → `getPlanDay` 不会把它算进每日计划进度;但 `updateStreak` 仍计入连胜(用户确实练了)。
|
||||
- 记录落库字段:`mode:'circuit'` + `sets/holdPerSet/restPerSet/sessionsPerWeek`(records 页按 `r.mode` 显示「循环 N组×Ms」)。
|
||||
- 阶段提示:TTS 只有 6 个固定 key 无"第N组/休息"词 → circuit 阶段切换用**震动 + 屏上 phaseTip 文本**,完成复用 `complete` 语音。
|
||||
- 进度环:circuit 模式轨道恒中性灰(否则每阶段都按"累计≥阶段长"误判变绿)。
|
||||
- 未做(可选后续):周频次硬追踪(仅存 sessionsPerWeek 作显示)、逐周进阶、设置页统一管理。
|
||||
- 验证:6 个 JS 全过 `node --check`;未动云函数/榜单/云同步。
|
||||
|
||||
@@ -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。
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -7,7 +7,7 @@ module.exports = {
|
||||
version: 'v3.1',
|
||||
|
||||
/** 最后更新日期,外显在设置页「关于」 */
|
||||
updatedAt: '2026-07-31',
|
||||
updatedAt: '2026-08-03',
|
||||
|
||||
/** 开发者名称 / 微信号,设置页点击可复制 */
|
||||
developer: '三口一瓶',
|
||||
|
||||
@@ -34,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()
|
||||
},
|
||||
|
||||
@@ -206,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">`
|
||||
|
||||
+66
-2
@@ -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>
|
||||
@@ -99,6 +99,15 @@
|
||||
icon-src="{{icons.timeFill}}"
|
||||
bindtap="onFreeTrain"
|
||||
></ui-btn>
|
||||
|
||||
<ui-btn
|
||||
variant="ghost"
|
||||
size="md"
|
||||
block
|
||||
text="循环训练 · 分组练习"
|
||||
icon-src="{{icons.repeatFill}}"
|
||||
bindtap="onCircuitTrain"
|
||||
></ui-btn>
|
||||
</view>
|
||||
|
||||
<!-- 时间选择弹窗 -->
|
||||
@@ -142,4 +151,59 @@
|
||||
></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"
|
||||
></ui-btn>
|
||||
<text class="picker-cancel" bindtap="onCloseCircuitPicker">取消</text>
|
||||
</ui-modal>
|
||||
</view>
|
||||
|
||||
+65
-2
@@ -404,11 +404,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 而非 border:border 会撑大宿主 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 {
|
||||
|
||||
@@ -68,7 +68,16 @@ Page({
|
||||
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).
|
||||
@@ -77,7 +86,7 @@ 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
|
||||
|
||||
@@ -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">
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<!-- 训练趋势 -->
|
||||
<block wx:if="{{contentMode === 'trend'}}">
|
||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||
<ui-card variant="in" index="2" hidden="{{totalSessions === 0}}">
|
||||
<view class="trend-section">
|
||||
<view class="trend-header">
|
||||
<view class="trend-title-wrap">
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
<!-- 日历热力图 -->
|
||||
<block wx:if="{{contentMode === 'records'}}">
|
||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||
<ui-card variant="in" index="2" hidden="{{totalSessions === 0}}">
|
||||
<view class="calendar-section">
|
||||
<view class="month-picker">
|
||||
<view class="month-arrow" bindtap="onPrevMonth">
|
||||
@@ -159,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">
|
||||
@@ -190,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+344
-6
@@ -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.
|
||||
@@ -290,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()
|
||||
@@ -308,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()
|
||||
@@ -400,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
|
||||
@@ -427,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
|
||||
@@ -475,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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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"/>',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+14
-3
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user