Files
wx_pbzc/pages/timer/timer.js
T
lc b52db87ddf feat: 排行榜虚拟领奖台 + 勋章对齐与尺寸统一
- 领奖台数据不足 3 名时显示虚拟空位(虚线轮廓+虚位以待)
- 领奖台勋章与昵称对齐改为等高盒结构性修复(移除 margin 盲推)
- 全 app 成就勋章图标尺寸统一为 30rpx(前三名/列表/设置页/记录页一致)
- 勋章 SVG 按包围盒精确居中(utils/icons.js)
- 设置页/记录页勋章展示配套调整
2026-08-04 11:23:48 +08:00

890 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '',
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)
}
this.setData({
duration: target,
remaining: target,
todayTarget: target,
planDay: isFreeMode ? 0 : planDay,
isFreeMode,
goalReached: false,
elapsed: 0
})
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
})
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 })
}
})
},
/**
* 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
* 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) => 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++) {
// 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' })
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' })
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' })
},
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' })
}
},
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
})
},
// 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)
}
}
})