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: '已完成一半啦,坚持就是胜利!',
|
halfway: '已完成一半啦,坚持就是胜利!',
|
||||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||||
last10: '最后10秒,再加把劲!',
|
last10: '最后10秒,再加把劲!',
|
||||||
|
goal: '目标达成,继续挑战!',
|
||||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/** 应用版本号,外显在设置页「关于」 */
|
/** 应用版本号,外显在设置页「关于」 */
|
||||||
version: 'v2.5',
|
version: 'v2.6',
|
||||||
|
|
||||||
/** 最后更新日期,外显在设置页「关于」 */
|
/** 最后更新日期,外显在设置页「关于」 */
|
||||||
updatedAt: '2026-07-09',
|
updatedAt: '2026-07-09',
|
||||||
|
|||||||
+23
-17
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const PROMPTS = {
|
|||||||
halfway: '已完成一半啦,坚持就是胜利!',
|
halfway: '已完成一半啦,坚持就是胜利!',
|
||||||
last30: '最后30秒,保持呼吸,稳住姿势!',
|
last30: '最后30秒,保持呼吸,稳住姿势!',
|
||||||
last10: '最后10秒,再加把劲!',
|
last10: '最后10秒,再加把劲!',
|
||||||
|
goal: '目标达成,继续挑战!',
|
||||||
complete: '太棒了!今天的目标已完成,继续加油!'
|
complete: '太棒了!今天的目标已完成,继续加油!'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user