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:
2026-08-03 16:34:22 +08:00
parent 76cd2b6c0f
commit 3532d0111b
23 changed files with 1433 additions and 44 deletions
+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,
+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 }