Files
wx_pbzc/pages/timer/timer.js
T
lc 326bc013fe fix(timer): 完成页圆环覆盖弹窗 + 自定义确认 modal 替代 wx.showModal
训练完成的三个相关 bug:
1. progress-ring 圆环覆盖完成页弹窗 → @tap ring wx:if 让完成态时直接 unmount
2. 完成弹窗内按钮需要点 2 次才能响应 → completion-mask 加 catchtouchmove 拦截 touchmove
3. wx.showModal "结束训练" 系统弹窗在 stacking context 下 hit-test 不稳

根因: .timer-page.page-enter 永久保留 transform: translateY(0),
创建永久 stacking context,影响 native modal 的 WebView 部分 hit-test。

修法:
- 新增自定义 stop-confirm modal (catchtouchmove + 内部 catchtap + bindtap),
  与 ui-modal/settings.plan-editor-mask 同模式,行为可靠
- completion-mask 用相同模式拦截 touchmove
- 顺便修 overtime-badge 暗色 (rgba(var(--success-rgb)))
2026-07-08 15:21:46 +08:00

398 lines
12 KiB
JavaScript

const Timer = require('../../utils/timer')
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')
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,
overtime: 0,
todayTarget: 0,
planDay: 1,
isFreeMode: false,
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: '',
icons: iconsMod.build()
},
onLoad(options) {
themeMod.applyThemeToPage(this)
this._init(options)
// 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) })
},
_init(options) {
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)
target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays))
}
this.setData({
duration: target,
remaining: target,
todayTarget: target,
planDay: isFreeMode ? 0 : planDay,
isFreeMode
})
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._timer = new Timer({
onTick: (tick) => {
this.setData({
remaining: tick.remaining > 0 ? tick.remaining : 0,
overtime: tick.remaining <= 0 ? tick.elapsed - this.data.duration : 0
})
this._remind(tick)
},
onComplete: () => {
const elapsed = this._timer.elapsed
const s = storage.getSettings()
if (s.vibrate) {
try { wx.vibrateLong() } catch (e) {}
}
if (s.voiceGuide !== false) {
voice.play('complete')
}
// Auto-save + show big completion modal
this._saveAndShowCompletion(elapsed)
}
})
},
/**
* 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 onComplete above
*
* 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) => {
if (!s.vibrate) return
try {
for (let i = 0; i < n; i++) {
setTimeout(() => wx.vibrateShort(), i * (ms + 30))
}
} 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)
}
},
/**
* Detect which celebration level to show. Called at onComplete (BEFORE
* saveRecord fires in finishTraining), 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()
const nextStreak = (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()
if (this._timer) {
this._timer.stop()
this._timer = null
}
if (this._countUpTask) {
this._countUpTask.cancel()
this._countUpTask = null
}
},
onStart() {
if (this.data.isPaused) {
this._timer.resume()
this.setData({ isRunning: true, isPaused: false, status: 'running' })
return
}
if (this.data.isRunning) return
// 取消可能还在滚的 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.setData({ isPaused: true, status: 'paused' })
},
onStop() {
// 用自定义确认弹窗替代 wx.showModal:
// 系统原生 modal 在 WebView stacking context 下 hit-test 偶发不稳,
// 自定义 modal + catchtouchmove 与现有 completion-mask 同模式,行为更可靠。
this.setData({ showStopConfirm: true })
},
onStopCancel() {
this.setData({ showStopConfirm: false })
},
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
this._saving = true
const elapsed = this._timer.stop()
if (elapsed < 3) {
this._saving = false
wx.showToast({ title: '训练时间太短', icon: 'none', duration: 1500 })
setTimeout(() => { wx.navigateBack() }, 1500)
return
}
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.
*/
_saveAndShowCompletion(elapsed) {
const { level, message } = this._detectCelebrationLevel(elapsed)
const confettiPieces = this._buildConfettiPieces(level)
// Save record + streak
const today = storage.getToday()
storage.saveRecord({
date: today,
duration: elapsed,
planId: storage.getSettings().planId,
day: this.data.planDay
})
storage.updateStreak(today)
// Flag for the index page's "训练完成" highlight
try { wx.setStorageSync('_last_train_at', Date.now()) } catch (e) {}
const finalStreak = storage.getStreak().count
this.setData({
// Background effects keep playing behind the modal
status: 'completed',
isCompleted: true,
celebrationLevel: level,
celebrationMessage: message,
confettiPieces,
// Modal state
showCompletion: true,
completionElapsed: 0,
completionTarget: this.data.duration,
completionOvertime: Math.max(0, elapsed - this.data.duration),
completionStreak: finalStreak,
completionLevel: level,
completionMessage: message
})
// 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
}
this.setData({
showCompletion: false,
isCompleted: false,
status: 'idle',
isRunning: false,
isPaused: false,
remaining: this.data.duration,
overtime: 0,
// Re-init countUp so the big number rolls again on next start
_countUpTask: null
})
},
// 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 */ }
})