fix: 振动定时器清理、完成保存守卫、暗黑冷启动、头像云存储

- timer: 振动 setTimeout 入栈追踪,暂停/停止/卸载/重练统一清理,
  避免页面销毁后残留震动(_clearVibrateTimers)
- timer: _saving 守卫统一到 _saveAndShowCompletion,自然完成与手动
  结束两路径都防重复保存;修复 onTrainAgain 不重置会话状态导致
  再练一次后第二次无语音/振动且保存被挡
- app.wxss: @media dark 补全 --text/--card-bg/--bg-soft/--border/
  --success/--danger,冷启动即为完整深色,不再背景深卡片白的割裂闪烁
- settings: 头像优先上传云存储存 fileID,三级回退(fileID->base64->
  临时URL),pushAll 不再每次传输图片字节

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 09:17:27 +08:00
parent 850f919269
commit 6fffc5aece
3 changed files with 89 additions and 26 deletions
+39 -3
View File
@@ -94,6 +94,7 @@ Page({
this._lastMinuteAt = 0
this._lastCountdown = 0
this._saving = false
this._clearVibrateTimers()
this._timer = new Timer({
onTick: (tick) => {
@@ -135,7 +136,14 @@ Page({
if (!s.vibrate) return
try {
for (let i = 0; i < n; i++) {
setTimeout(() => wx.vibrateShort(), i * (ms + 30))
// 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(() => {
wx.vibrateShort()
const idx = this._vibrateTimers.indexOf(id)
if (idx > -1) this._vibrateTimers.splice(idx, 1)
}, i * (ms + 30))
this._vibrateTimers.push(id)
}
} catch (e) {}
}
@@ -174,6 +182,19 @@ Page({
}
},
/**
* 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 = []
},
/**
* Detect which celebration level to show. Called at onComplete (BEFORE
* saveRecord fires in finishTraining), so stats reflect pre-save state.
@@ -224,6 +245,7 @@ Page({
onUnload() {
voice.stop()
voice.destroy()
this._clearVibrateTimers()
if (this._timer) {
this._timer.stop()
this._timer = null
@@ -257,6 +279,7 @@ Page({
if (!this.data.isRunning || this.data.isPaused) return
this._timer.pause()
voice.stop()
this._clearVibrateTimers()
this.setData({ isPaused: true, status: 'paused' })
},
@@ -281,11 +304,10 @@ Page({
// a second tap on the underlying 结束 button is harmless — but we
// still need to guard the save itself.
if (this._saving) return
this._saving = true
const elapsed = this._timer.stop()
this._clearVibrateTimers()
if (elapsed < 3) {
this._saving = false
wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 })
setTimeout(() => { wx.navigateBack() }, 1500)
return
@@ -300,6 +322,11 @@ Page({
* 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.
if (this._saving) return
this._saving = true
const { level, message } = this._detectCelebrationLevel(elapsed)
const confettiPieces = this._buildConfettiPieces(level)
@@ -377,6 +404,15 @@ Page({
this._completionCountUp.cancel()
this._completionCountUp = null
}
// 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._clearVibrateTimers()
this.setData({
showCompletion: false,
isCompleted: false,