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') const iconsMod = require('../../utils/icons') const voice = require('../../utils/voice') const { countUp } = require('../../utils/countUp') const config = require('../../config') Page({ data: { theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' }, themeStyle: themeMod.BASE_VARS, duration: 60, remaining: 0, status: 'idle', isRunning: false, isPaused: false, isCompleted: false, elapsed: 0, goalReached: false, hintTip: (config.postureTips && config.postureTips[0]) || '', 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: [], // Completion modal (full-screen summary after training ends) showCompletion: false, showStopConfirm: false, // 自定义"结束训练"确认弹窗,替代 wx.showModal completionElapsed: 0, completionTarget: 0, completionOvertime: 0, completionStreak: 0, completionLevel: 'normal', completionMessage: '', // 完成弹窗科学提示(config.scienceTips 按本次时长选段) completionTipValue: '', completionTipRisk: '', // --- 计时页改版派生字段(状态文案 / 目标信息卡 / 环刻度 / 环内副标题) --- ringTicks: 0, ringSubText: '', statusText: '准备开始', goalLine: '', runningTip: '', tipType: 'posture', tipIcon: '', icons: iconsMod.build() }, onLoad(options) { themeMod.applyThemeToPage(this) this._init(options) // 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)。 // 按模式取词条:循环模式要 restStart/nextSet/lastSet,单组模式要 goal, // 各自不载对方的,省掉无谓的云函数调用与下载。 const s = storage.getSettings() 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() this._countUpTask = countUp({ from: 0, to: target, duration: 600, onUpdate: (v) => this.setData({ remaining: v }), onComplete: () => { this._countUpTask = null } }) }, onShow() { 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 if (options && options.free) { const parsed = parseInt(options.free) target = parsed > 0 ? parsed : 60 isFreeMode = true } else { const settings = storage.getSettings() const allRecords = Object.values(storage.getRecords()).flat() const customPlans = storage.getCustomPlans() const plan = planMod.getPlan(settings.planId, customPlans) planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate, customPlans) target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays), customPlans) } const durText = this._durationText(target) this.setData({ duration: target, remaining: target, todayTarget: target, planDay: isFreeMode ? 0 : planDay, isFreeMode, goalReached: false, elapsed: 0, goalLine: isFreeMode ? `自由训练 · 目标 ${durText}` : `今日目标 ${durText} · 第 ${planDay} 天`, ringTicks: Math.min(target, 60), ringSubText: `目标 ${durText}`, statusText: '准备开始', runningTip: '', tipType: 'posture', tipIcon: this.data.icons.peopleFill }) if (this._timer) { this._timer.stop() this._timer = null } // Reset reminder state this._halfwaySignaled = false this._last30Signaled = false this._last10Signaled = false this._lastMinuteAt = 0 this._lastCountdown = 0 this._saving = false this._lastTipIndex = -1 this._clearVibrateTimers() this._timer = new Timer({ onTick: (tick) => { this.setData({ remaining: tick.remaining > 0 ? tick.remaining : 0, elapsed: tick.elapsed, runningTip: this.data.goalReached ? '目标达成,继续挑战!' : this.data.hintTip, tipType: this.data.goalReached ? 'done' : 'posture', tipIcon: this.data.goalReached ? this.data.icons.successFill : this.data.icons.peopleFill }) this._updateHintTip(tick) this._remind(tick) }, onGoalReached: () => { // Target hit - don't end. Voice prompt only (no vibration, per // design); keep counting so the user can train to failure. const s = storage.getSettings() if (s.voiceGuide !== false) { voice.play('goal') } this.setData({ goalReached: true, runningTip: '目标达成,继续挑战!', tipType: 'done', tipIcon: this.data.icons.successFill }) } }) }, /** * 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, statusText: resting ? '休息中' : '撑住', runningTip: resting ? '' : this.data.phaseTip, tipType: 'phase', tipIcon: this.data.icons.peopleFill }) 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) } const durText = this._durationText(hold) 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, goalLine: `每组 ${durText} · 共 ${sets} 组`, ringTicks: Math.min(hold, 60), ringSubText: `每组 ${durText}`, statusText: '准备开始', runningTip: '', tipType: 'posture', tipIcon: this.data.icons.peopleFill }) }, _int(v, fallback, min, max) { const n = parseInt(v) if (!Number.isFinite(n)) return fallback return Math.max(min, Math.min(max, n)) }, /** * 把秒数转成口语化时长文本:45 -> "45秒", 90 -> "1分30", 120 -> "2分钟"。 * 用于目标信息卡与环内副标题,比 MM:SS 更贴近自然语言。 */ _durationText(sec) { const s = Math.floor(sec || 0) if (s < 60) return s + '秒' const m = Math.floor(s / 60) const rem = s % 60 return rem === 0 ? m + '分钟' : m + '分' + rem }, /** * 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) => {} 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 * switches to the "goal reached" line). Skips setData when the index * hasn't changed to avoid per-second re-renders. */ _updateHintTip(tick) { if (tick.remaining <= 0) return const tips = config.postureTips || [] if (tips.length === 0) return const interval = config.tipInterval || 12 const idx = Math.floor(tick.elapsed / interval) % tips.length if (idx !== this._lastTipIndex) { this._lastTipIndex = idx this.setData({ hintTip: tips[idx] }) } }, /** * Per-tick reminders: vibration + voice prompts at 4 fixed points. * - halfway: once when elapsed reaches half of duration * - 30s: once when remaining hits 30 * - 10s: once when remaining hits 10 * - complete: fired in finishTraining when the user ends the session * * Vibration and voice are independently gated by `vibrate` / `voiceGuide` * settings — user can mute haptics but keep voice (e.g. in office) or vice versa. */ _remind(tick) { const s = storage.getSettings() const { remaining, elapsed, duration } = tick const vibrate = (n, ms) => { // `=== false` (not `!s.vibrate`) so an undefined field (legacy users // whose settings predate the toggle) is treated as ON, matching the // voiceGuide check below. `!s.vibrate` used to see undefined as falsy // and silently skip vibration until the user toggled the switch. if (s.vibrate === false) return const intensity = s.vibrateIntensity || 'heavy' // light/medium = vibrateShort(type); heavy = vibrateLong(400ms). // vibrateLong is the most reliable on iOS real devices; the short // types are nicer but may be silent on some devices - user picks. const doVibrate = () => { const fail = (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++) { // Track timers so pause/stop/unload can cancel pending bursts // instead of firing vibrations on a page that's already torn down. 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) {} } // Each prompt is gated on a minimum duration so short free-mode // trainings don't trigger them at nonsensical times (e.g. firing // "最后30秒" on the first tick of a 30s session because remaining // happens to equal 30 at elapsed=0). // // Thresholds chosen so prompts fire in the right ORDER for any // duration: halfway first, then last30, then last10. // 1. Halfway: needs >=10s total so the midpoint is at least 5s in if (duration >= 10 && !this._halfwaySignaled && elapsed >= Math.floor(duration / 2)) { this._halfwaySignaled = true if (s.voiceGuide !== false) voice.play('halfway') vibrate(2, 150) } // 2. Last 30 seconds: only if duration > 60s so halfway has already passed if (duration > 60 && remaining === 30 && !this._last30Signaled) { this._last30Signaled = true if (s.voiceGuide !== false) voice.play('last30') } // 3. Last 10 seconds: only if duration > 20s so we have at least 10s of training if (duration > 20 && remaining === 10 && !this._last10Signaled) { this._last10Signaled = true if (s.voiceGuide !== false) voice.play('last10') } // 4. Countdown buzzes at 5..1: only if duration >= 6s (so we can reach them) if (duration >= 6 && remaining <= 5 && remaining > 0 && remaining !== this._lastCountdown) { this._lastCountdown = remaining vibrate(1, 100) } }, /** * Cancel any pending vibration timers. Tracked so pause/stop/unload can * abort scheduled bursts instead of firing on a torn-down page. */ _clearVibrateTimers() { if (!this._vibrateTimers) { this._vibrateTimers = [] return } this._vibrateTimers.forEach((id) => clearTimeout(id)) 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. * - first: user's very first training ever * - milestone: streak will hit a memorable number after this save * - record: this duration beats the user's previous max * - normal: everything else */ _detectCelebrationLevel(elapsed) { const stats = storage.getTotalStats() const streak = storage.getStreak() // 本函数运行在保存记录之前(pre-save state)。updateStreak 对「同一天再次训练」 // 会直接 return、不递增连胜数;所以若 streak.lastDate 已是今天,nextStreak 应取 // 当前 count 而非 count+1,否则庆祝文案会比真实连胜多 1 天。 const todayOnly = storage.getToday().substring(0, 10) const lastIsToday = !!(streak.lastDate && streak.lastDate.substring(0, 10) === todayOnly) const nextStreak = lastIsToday ? (streak.count || 1) : ((streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1) if (stats.totalSessions === 0) { return { level: 'first', message: '第一次训练!坚持就是胜利' } } if ([7, 14, 30, 50, 100, 365].includes(nextStreak)) { return { level: 'milestone', message: `连续打卡 ${nextStreak} 天!` } } if (elapsed > stats.maxDuration) { return { level: 'record', message: `新纪录!${elapsed}秒` } } return { level: 'normal', message: '完成!' } }, /** * Build the confetti piece list. More pieces + more colors for higher * celebration levels. Pieces are pre-positioned so the layout doesn't * shift when wx:for renders them. */ _buildConfettiPieces(level) { const counts = { normal: 12, record: 24, milestone: 32, first: 40 } const count = counts[level] || counts.normal const palette = ['#FF6B35', '#FFD93D', '#4CAF50', '#2979FF', '#EC407A', '#7E57C2', '#FF8C5A', '#5C9CFF', '#6EC072', '#A08AD6'] const pieces = [] for (let i = 0; i < count; i++) { pieces.push({ left: Math.round((i / count) * 96 + 2), color: palette[i % palette.length], delay: Math.round(Math.random() * 300) / 1000, rotate: Math.round((Math.random() * 90) - 45), size: level === 'first' ? 40 : level === 'milestone' ? 36 : 32 }) } return pieces }, 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() this._timer = null } if (this._countUpTask) { this._countUpTask.cancel() this._countUpTask = null } if (this._completionCountUp) { this._completionCountUp.cancel() this._completionCountUp = null } }, 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', statusText: '撑住', runningTip: '' }) 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() this._countUpTask = null this.setData({ remaining: this.data.duration }) } this._timer.start(this.data.duration) this.setData({ isRunning: true, status: 'running', statusText: '撑住', runningTip: '' }) const s = storage.getSettings() if (s.voiceGuide !== false) voice.play('start') }, onPause() { if (!this.data.isRunning || this.data.isPaused) return this._timer.pause() voice.stop() this._clearVibrateTimers() this.setData({ isPaused: true, status: 'paused', statusText: '已暂停', runningTip: '' }) }, onStop() { // 用自定义确认弹窗替代 wx.showModal: // 系统原生 modal 在 WebView stacking context 下 hit-test 偶发不稳, // 自定义 modal + catchtouchmove 与现有 completion-mask 同模式,行为更可靠。 // 弹窗停留期间暂停计时,避免犹豫时间被计入训练时长 this._stopWasPaused = this.data.isPaused const patch = { showStopConfirm: true } if (this.data.isRunning && !this.data.isPaused) { this._timer.pause() patch.isPaused = true patch.status = 'paused' } this.setData(patch) }, onStopCancel() { this.setData({ showStopConfirm: false }) // 弹窗前若正在计时(被 onStop 暂停),取消后恢复 if (!this._stopWasPaused && this.data.isRunning) { this._timer.resume() this.setData({ isRunning: true, isPaused: false, status: 'running', statusText: '撑住', runningTip: '' }) } }, onStopConfirm() { this.setData({ showStopConfirm: false }) this.finishTraining() }, finishTraining() { // Guard against double-tap: completion modal shows immediately, so // a second tap on the underlying 结束 button is harmless — but we // still need to guard the save itself. if (this._saving) return const elapsed = this._timer.stop() this._clearVibrateTimers() if (elapsed < 3) { wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 }) setTimeout(() => { wx.navigateBack() }, 1500) return } // Completion voice (replaces the old auto-onComplete cue). No vibration. const s = storage.getSettings() if (s.voiceGuide !== false) { voice.play('complete') } this._saveAndShowCompletion(elapsed) }, /** * 按本次实际训练时长从 config.scienceTips 选一段提示。 * 命中第一条 maxSeconds 大于 elapsed 的配置;缺失配置时返回空(不显示)。 */ _pickScienceTip(elapsed) { const tips = config.scienceTips || [] const sec = Math.max(0, Number(elapsed) || 0) for (const t of tips) { const max = t && t.maxSeconds == null ? Infinity : (t && t.maxSeconds) if (sec < max) { return { value: (t && t.value) || '', risk: (t && t.risk) || '' } } } return { value: '', risk: '' } }, /** * Persist the just-finished training and pop the big completion modal. * Called from finishTraining (the user manually ends; the timer keeps * running past the goal until they stop). Detection of "first / record * / milestone" uses pre-save stats so the level is decided before the * data changes. */ _saveAndShowCompletion(elapsed) { // Re-entry guard: protects the finishTraining path so the save never // runs twice even if the user double-taps end. if (this._saving) return this._saving = true const { level, message } = this._detectCelebrationLevel(elapsed) const confettiPieces = this._buildConfettiPieces(level) // Save record + streak const today = storage.getToday() const record = { date: today, duration: elapsed, // 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 try { wx.setStorageSync('_last_train_at', Date.now()) } catch (e) {} // 标记排行榜强刷:下次打开排行榜跳过云函数缓存,让刚训练的数据立即可见 try { wx.setStorageSync('_lb_force_refresh', true) } catch (e) {} const finalStreak = storage.getStreak().count const scienceTip = this._pickScienceTip(elapsed) this.setData({ // Background effects keep playing behind the modal status: 'completed', isCompleted: true, celebrationLevel: level, celebrationMessage: message, confettiPieces, // Modal state showCompletion: true, completionElapsed: 0, completionActualElapsed: elapsed, completionTarget: 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, // 科学提示(按时长分段;无配置时为空字符串,wx:if 不渲染) completionTipValue: scienceTip.value, completionTipRisk: scienceTip.risk }) // Animate the big number from 0 to elapsed if (this._completionCountUp) this._completionCountUp.cancel() this._completionCountUp = countUp({ from: 0, to: elapsed, duration: 1200, onUpdate: (v) => this.setData({ completionElapsed: v }), onComplete: () => { this._completionCountUp = null } }) }, onCloseCompletion() { if (this._completionCountUp) { this._completionCountUp.cancel() this._completionCountUp = null } this.setData({ showCompletion: false }) setTimeout(() => { wx.navigateBack() }, 280) }, onViewRecords() { if (this._completionCountUp) { this._completionCountUp.cancel() this._completionCountUp = null } this.setData({ showCompletion: false }) setTimeout(() => { wx.navigateBack({ success: () => { setTimeout(() => wx.switchTab({ url: '/pages/records/records' }), 100) } }) }, 280) }, onTrainAgain() { // Reset the timer to idle so the user can start a new session right // here (no need to navigate back and re-tap 开始). if (this._completionCountUp) { 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 this._halfwaySignaled = false this._last30Signaled = false this._last10Signaled = false this._lastMinuteAt = 0 this._lastCountdown = 0 this._lastTipIndex = -1 this._clearVibrateTimers() this._countUpTask = null this.setData({ showCompletion: false, isCompleted: false, status: 'idle', isRunning: false, isPaused: false, remaining: this.data.duration, elapsed: 0, goalReached: false, statusText: '准备开始', runningTip: '', tipType: 'posture', tipIcon: this.data.icons.peopleFill }) }, // No-op handler for catchtouchmove. Swallows move events that would // otherwise bubble to underlying timer page controls (causing missed // taps on the completion modal). onNoop() { /* swallow */ }, /** * 分享给朋友。训练完成弹窗里的分享按钮会走到这里,标题里拼上用户 * 刚才撑了多久,转化率更高;右上角"···"菜单也共用同一份文案。 */ onShareAppMessage() { const elapsed = this.data.completionActualElapsed || this.data.duration const s = config.share.timer return { title: s.titleTemplate.replace('{elapsed}', elapsed), path: s.path } }, /** * 分享到朋友圈,同样带上刚完成的时长晒成绩。 */ onShareTimeline() { const elapsed = this.data.completionActualElapsed || this.data.duration return { title: config.share.timer.timelineTitleTemplate.replace('{elapsed}', elapsed) } } })