3532d0111b
循环训练(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(静默降级不报错)
195 lines
5.5 KiB
JavaScript
195 lines
5.5 KiB
JavaScript
/**
|
|
* 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
|