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:
@@ -24,6 +24,7 @@ const PROMPTS = {
|
||||
halfway: '已完成一半啦,坚持就是胜利!',
|
||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||
last10: '最后10秒,再加把劲!',
|
||||
goal: '目标达成,继续挑战!',
|
||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
module.exports = {
|
||||
/** 应用版本号,外显在设置页「关于」 */
|
||||
version: 'v2.5',
|
||||
version: 'v2.6',
|
||||
|
||||
/** 最后更新日期,外显在设置页「关于」 */
|
||||
updatedAt: '2026-07-09',
|
||||
|
||||
+23
-17
@@ -17,6 +17,7 @@ Page({
|
||||
isPaused: false,
|
||||
isCompleted: false,
|
||||
overtime: 0,
|
||||
goalReached: false,
|
||||
todayTarget: 0,
|
||||
planDay: 1,
|
||||
isFreeMode: false,
|
||||
@@ -79,7 +80,8 @@ Page({
|
||||
remaining: target,
|
||||
todayTarget: target,
|
||||
planDay: isFreeMode ? 0 : planDay,
|
||||
isFreeMode
|
||||
isFreeMode,
|
||||
goalReached: false
|
||||
})
|
||||
|
||||
if (this._timer) {
|
||||
@@ -104,17 +106,14 @@ Page({
|
||||
})
|
||||
this._remind(tick)
|
||||
},
|
||||
onComplete: () => {
|
||||
const elapsed = this._timer.elapsed
|
||||
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.vibrate) {
|
||||
try { wx.vibrateLong() } catch (e) {}
|
||||
}
|
||||
if (s.voiceGuide !== false) {
|
||||
voice.play('complete')
|
||||
voice.play('goal')
|
||||
}
|
||||
// Auto-save + show big completion modal
|
||||
this._saveAndShowCompletion(elapsed)
|
||||
this.setData({ goalReached: true })
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -124,7 +123,7 @@ Page({
|
||||
* - halfway: once when elapsed reaches half of duration
|
||||
* - 30s: once when remaining hits 30
|
||||
* - 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`
|
||||
* 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
|
||||
* saveRecord fires in finishTraining), so stats reflect pre-save state.
|
||||
* 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
|
||||
@@ -312,18 +311,24 @@ Page({
|
||||
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)
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist the just-finished training and pop the big completion modal.
|
||||
* Called from both onComplete (natural finish) and finishTraining
|
||||
* (user ended early). Detection of "first / record / milestone" uses
|
||||
* pre-save stats so the level is decided before the data changes.
|
||||
* 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) {
|
||||
// Unified re-entry guard: covers both the natural-complete (onComplete)
|
||||
// and manual-stop (finishTraining) paths so the save never runs twice.
|
||||
// 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
|
||||
|
||||
@@ -421,6 +426,7 @@ Page({
|
||||
isPaused: false,
|
||||
remaining: this.data.duration,
|
||||
overtime: 0,
|
||||
goalReached: false,
|
||||
// Re-init countUp so the big number rolls again on next start
|
||||
_countUpTask: null
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<view class="spark s6">💪</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>
|
||||
<text>+{{overtime}}s</text>
|
||||
</view>
|
||||
@@ -61,6 +61,10 @@
|
||||
<block wx:else>今日目标 {{duration}} 秒 · 第 {{planDay}} 天</block>
|
||||
</text>
|
||||
</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'}}">
|
||||
<image class="hint-icon running-pulse" src="{{icons.likeFill}}" mode="aspectFit"></image>
|
||||
<text class="hint-text running">保持姿势,核心收紧!</text>
|
||||
@@ -145,7 +149,7 @@
|
||||
|
||||
<view class="completion-overtime" wx:if="{{completionOvertime > 0}}">
|
||||
<text class="overtime-plus">+{{completionOvertime}}</text>
|
||||
<text class="overtime-label">秒超时</text>
|
||||
<text class="overtime-label">秒超长发挥</text>
|
||||
</view>
|
||||
|
||||
<view class="completion-streak" wx:if="{{completionStreak > 0}}">
|
||||
|
||||
+10
-7
@@ -1,7 +1,7 @@
|
||||
class Timer {
|
||||
constructor(options = {}) {
|
||||
this.onTick = options.onTick || (() => {})
|
||||
this.onComplete = options.onComplete || (() => {})
|
||||
this.onGoalReached = options.onGoalReached || (() => {})
|
||||
|
||||
this._duration = 0
|
||||
this._remaining = 0
|
||||
@@ -11,7 +11,7 @@ class Timer {
|
||||
this._intervalId = null
|
||||
this._running = false
|
||||
this._paused = false
|
||||
this._completed = false
|
||||
this._goalReached = false
|
||||
this._appShowBound = false
|
||||
|
||||
// WeChat freezes setInterval while the mini-program is in background.
|
||||
@@ -29,7 +29,7 @@ class Timer {
|
||||
this._duration = duration
|
||||
this._remaining = duration
|
||||
this._elapsed = 0
|
||||
this._completed = false
|
||||
this._goalReached = false
|
||||
this._running = true
|
||||
this._paused = false
|
||||
this._startTime = Date.now()
|
||||
@@ -81,15 +81,18 @@ class Timer {
|
||||
duration: this._duration
|
||||
})
|
||||
|
||||
if (this._remaining <= 0 && !this._completed) {
|
||||
this._completed = true
|
||||
this.onComplete({ elapsed: this._elapsed, duration: this._duration })
|
||||
// Goal reached: fire once but DON'T stop - the user keeps training past
|
||||
// the target until they manually end. elapsed keeps climbing, so
|
||||
// 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 isPaused() { return this._paused }
|
||||
get isCompleted() { return this._completed }
|
||||
get isGoalReached() { return this._goalReached }
|
||||
get remaining() { return this._remaining }
|
||||
get elapsed() { return this._elapsed }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const PROMPTS = {
|
||||
halfway: '已完成一半啦,坚持就是胜利!',
|
||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||
last10: '最后10秒,再加把劲!',
|
||||
goal: '目标达成,继续挑战!',
|
||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user