feat(timer): 训练到目标时长不再自动结束,支持超长发挥

- Timer: 到时间触发 onGoalReached(提示)而非自动结束,elapsed/overtime
  继续累加直到用户手动停止
- timer: onGoalReached 播 goal 语音 + goalReached 状态;finishTraining
  补 complete 语音;完成弹窗显示"超长发挥 +X 秒"
- wxml: 超时 badge 训练中也显示;新增 goalReached hint 分支
- voice/tts: 新增 goal 语音 prompt(需部署 tts 云函数)
- config: 版本号 v2.5 -> v2.6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 09:37:32 +08:00
parent 6fffc5aece
commit dd9cd1859c
6 changed files with 42 additions and 27 deletions
+1
View File
@@ -24,6 +24,7 @@ const PROMPTS = {
halfway: '已完成一半啦,坚持就是胜利!', halfway: '已完成一半啦,坚持就是胜利!',
last30: '最后30秒,保持呼吸,稳住姿势!', last30: '最后30秒,保持呼吸,稳住姿势!',
last10: '最后10秒,再加把劲!', last10: '最后10秒,再加把劲!',
goal: '目标达成,继续挑战!',
complete: '太棒了!今天的目标已完成,继续加油!' complete: '太棒了!今天的目标已完成,继续加油!'
} }
+1 -1
View File
@@ -4,7 +4,7 @@
*/ */
module.exports = { module.exports = {
/** 应用版本号,外显在设置页「关于」 */ /** 应用版本号,外显在设置页「关于」 */
version: 'v2.5', version: 'v2.6',
/** 最后更新日期,外显在设置页「关于」 */ /** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-07-09', updatedAt: '2026-07-09',
+23 -17
View File
@@ -17,6 +17,7 @@ Page({
isPaused: false, isPaused: false,
isCompleted: false, isCompleted: false,
overtime: 0, overtime: 0,
goalReached: false,
todayTarget: 0, todayTarget: 0,
planDay: 1, planDay: 1,
isFreeMode: false, isFreeMode: false,
@@ -79,7 +80,8 @@ Page({
remaining: target, remaining: target,
todayTarget: target, todayTarget: target,
planDay: isFreeMode ? 0 : planDay, planDay: isFreeMode ? 0 : planDay,
isFreeMode isFreeMode,
goalReached: false
}) })
if (this._timer) { if (this._timer) {
@@ -104,17 +106,14 @@ Page({
}) })
this._remind(tick) this._remind(tick)
}, },
onComplete: () => { onGoalReached: () => {
const elapsed = this._timer.elapsed // 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() const s = storage.getSettings()
if (s.vibrate) {
try { wx.vibrateLong() } catch (e) {}
}
if (s.voiceGuide !== false) { if (s.voiceGuide !== false) {
voice.play('complete') voice.play('goal')
} }
// Auto-save + show big completion modal this.setData({ goalReached: true })
this._saveAndShowCompletion(elapsed)
} }
}) })
}, },
@@ -124,7 +123,7 @@ Page({
* - halfway: once when elapsed reaches half of duration * - halfway: once when elapsed reaches half of duration
* - 30s: once when remaining hits 30 * - 30s: once when remaining hits 30
* - 10s: once when remaining hits 10 * - 10s: once when remaining hits 10
* - complete: fired in onComplete above * - complete: fired in finishTraining when the user ends the session
* *
* Vibration and voice are independently gated by `vibrate` / `voiceGuide` * Vibration and voice are independently gated by `vibrate` / `voiceGuide`
* settings — user can mute haptics but keep voice (e.g. in office) or vice versa. * settings — user can mute haptics but keep voice (e.g. in office) or vice versa.
@@ -196,8 +195,8 @@ Page({
}, },
/** /**
* Detect which celebration level to show. Called at onComplete (BEFORE * Detect which celebration level to show. Called from _saveAndShowCompletion
* saveRecord fires in finishTraining), so stats reflect pre-save state. * (runs in finishTraining, BEFORE saveRecord), so stats reflect pre-save state.
* - first: user's very first training ever * - first: user's very first training ever
* - milestone: streak will hit a memorable number after this save * - milestone: streak will hit a memorable number after this save
* - record: this duration beats the user's previous max * - record: this duration beats the user's previous max
@@ -312,18 +311,24 @@ Page({
setTimeout(() => { wx.navigateBack() }, 1500) setTimeout(() => { wx.navigateBack() }, 1500)
return 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) this._saveAndShowCompletion(elapsed)
}, },
/** /**
* Persist the just-finished training and pop the big completion modal. * Persist the just-finished training and pop the big completion modal.
* Called from both onComplete (natural finish) and finishTraining * Called from finishTraining (the user manually ends; the timer keeps
* (user ended early). Detection of "first / record / milestone" uses * running past the goal until they stop). Detection of "first / record
* pre-save stats so the level is decided before the data changes. * / milestone" uses pre-save stats so the level is decided before the
* data changes.
*/ */
_saveAndShowCompletion(elapsed) { _saveAndShowCompletion(elapsed) {
// Unified re-entry guard: covers both the natural-complete (onComplete) // Re-entry guard: protects the finishTraining path so the save never
// and manual-stop (finishTraining) paths so the save never runs twice. // runs twice even if the user double-taps end.
if (this._saving) return if (this._saving) return
this._saving = true this._saving = true
@@ -421,6 +426,7 @@ Page({
isPaused: false, isPaused: false,
remaining: this.data.duration, remaining: this.data.duration,
overtime: 0, overtime: 0,
goalReached: false,
// Re-init countUp so the big number rolls again on next start // Re-init countUp so the big number rolls again on next start
_countUpTask: null _countUpTask: null
}) })
+6 -2
View File
@@ -46,7 +46,7 @@
<view class="spark s6">💪</view> <view class="spark s6">💪</view>
</view> </view>
<view class="overtime-badge" wx:if="{{isCompleted && overtime > 0}}"> <view class="overtime-badge" wx:if="{{overtime > 0}}">
<image class="overtime-icon" src="{{icons.hotFill}}" mode="aspectFit"></image> <image class="overtime-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
<text>+{{overtime}}s</text> <text>+{{overtime}}s</text>
</view> </view>
@@ -61,6 +61,10 @@
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block> <block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
</text> </text>
</view> </view>
<view class="hint-row" wx:elif="{{status === 'running' && goalReached}}">
<image class="hint-icon running-pulse" src="{{icons.hotFill}}" mode="aspectFit"></image>
<text class="hint-text running">目标达成,继续挑战!</text>
</view>
<view class="hint-row" wx:elif="{{status === 'running'}}"> <view class="hint-row" wx:elif="{{status === 'running'}}">
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image> <image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
<text class="hint-text running">保持姿势,核心收紧!</text> <text class="hint-text running">保持姿势,核心收紧!</text>
@@ -145,7 +149,7 @@
<view class="completion-overtime" wx:if="{{completionOvertime > 0}}"> <view class="completion-overtime" wx:if="{{completionOvertime > 0}}">
<text class="overtime-plus">+{{completionOvertime}}</text> <text class="overtime-plus">+{{completionOvertime}}</text>
<text class="overtime-label">秒超</text> <text class="overtime-label">秒超长发挥</text>
</view> </view>
<view class="completion-streak" wx:if="{{completionStreak > 0}}"> <view class="completion-streak" wx:if="{{completionStreak > 0}}">
+10 -7
View File
@@ -1,7 +1,7 @@
class Timer { class Timer {
constructor(options = {}) { constructor(options = {}) {
this.onTick = options.onTick || (() => {}) this.onTick = options.onTick || (() => {})
this.onComplete = options.onComplete || (() => {}) this.onGoalReached = options.onGoalReached || (() => {})
this._duration = 0 this._duration = 0
this._remaining = 0 this._remaining = 0
@@ -11,7 +11,7 @@ class Timer {
this._intervalId = null this._intervalId = null
this._running = false this._running = false
this._paused = false this._paused = false
this._completed = false this._goalReached = false
this._appShowBound = false this._appShowBound = false
// WeChat freezes setInterval while the mini-program is in background. // WeChat freezes setInterval while the mini-program is in background.
@@ -29,7 +29,7 @@ class Timer {
this._duration = duration this._duration = duration
this._remaining = duration this._remaining = duration
this._elapsed = 0 this._elapsed = 0
this._completed = false this._goalReached = false
this._running = true this._running = true
this._paused = false this._paused = false
this._startTime = Date.now() this._startTime = Date.now()
@@ -81,15 +81,18 @@ class Timer {
duration: this._duration duration: this._duration
}) })
if (this._remaining <= 0 && !this._completed) { // Goal reached: fire once but DON'T stop - the user keeps training past
this._completed = true // the target until they manually end. elapsed keeps climbing, so
this.onComplete({ elapsed: this._elapsed, duration: this._duration }) // overtime (elapsed - duration) grows and the UI shows the surplus.
if (this._remaining <= 0 && !this._goalReached) {
this._goalReached = true
this.onGoalReached({ elapsed: this._elapsed, duration: this._duration })
} }
} }
get isRunning() { return this._running && !this._paused } get isRunning() { return this._running && !this._paused }
get isPaused() { return this._paused } get isPaused() { return this._paused }
get isCompleted() { return this._completed } get isGoalReached() { return this._goalReached }
get remaining() { return this._remaining } get remaining() { return this._remaining }
get elapsed() { return this._elapsed } get elapsed() { return this._elapsed }
} }
+1
View File
@@ -22,6 +22,7 @@ const PROMPTS = {
halfway: '已完成一半啦,坚持就是胜利!', halfway: '已完成一半啦,坚持就是胜利!',
last30: '最后30秒,保持呼吸,稳住姿势!', last30: '最后30秒,保持呼吸,稳住姿势!',
last10: '最后10秒,再加把劲!', last10: '最后10秒,再加把劲!',
goal: '目标达成,继续挑战!',
complete: '太棒了!今天的目标已完成,继续加油!' complete: '太棒了!今天的目标已完成,继续加油!'
} }