6176f2c341
- records: 底部列表与月份选择器联动,新增 [本月|最近] 分段控件;标题/空态随模式切换 - leaderboard: 领奖台布局(冠军居中+皇冠)、次数标注于底座且三档水平对齐;头像 cloud:// 批量预解析提速(云函数需重新部署) - index/timer: 主色底图标改白色变体修复隐形;目标数字按微信字体缩放补偿真机遮挡 - settings: 配色方案横滑渐变色卡;头像上传前压缩至 200px - theme: 新增 teal 主题;暗黑背景统一对齐 - 圆形头像双保险(image 自身 border-radius)修复月/年榜方图 注: cloudfunctions/leaderboard 改动须在开发者工具重新部署才生效。
113 lines
3.2 KiB
JavaScript
113 lines
3.2 KiB
JavaScript
class Timer {
|
|
constructor(options = {}) {
|
|
this.onTick = options.onTick || (() => {})
|
|
this.onGoalReached = options.onGoalReached || (() => {})
|
|
|
|
this._duration = 0
|
|
this._remaining = 0
|
|
this._elapsed = 0
|
|
this._startTime = 0
|
|
this._pausedTime = 0
|
|
this._intervalId = null
|
|
this._running = false
|
|
this._paused = false
|
|
this._goalReached = false
|
|
this._appShowBound = false
|
|
|
|
// WeChat freezes setInterval while the mini-program is in background.
|
|
// When the user returns, force an immediate tick so the UI jumps to
|
|
// the actual elapsed time (instead of staying on the last pre-background
|
|
// frame) and any missed halfway / 30s / 10s reminders can still fire.
|
|
this._onAppShow = () => {
|
|
if (this._running && !this._paused) {
|
|
this._tick()
|
|
}
|
|
}
|
|
}
|
|
|
|
start(duration) {
|
|
this._duration = duration
|
|
this._remaining = duration
|
|
this._elapsed = 0
|
|
this._goalReached = false
|
|
this._running = true
|
|
this._paused = false
|
|
this._startTime = Date.now()
|
|
this._tick()
|
|
this._intervalId = setInterval(() => { this._tick() }, 1000)
|
|
|
|
// Register foreground listener (idempotent — keep a flag so we don't
|
|
// double-register if start() is called multiple times in one session)
|
|
this._bindAppShow()
|
|
}
|
|
|
|
_bindAppShow() {
|
|
if (this._appShowBound || typeof wx.onAppShow !== 'function') return
|
|
try {
|
|
wx.onAppShow(this._onAppShow)
|
|
this._appShowBound = true
|
|
} catch (e) {}
|
|
}
|
|
|
|
_unbindAppShow() {
|
|
if (!this._appShowBound || typeof wx.offAppShow !== 'function') return
|
|
try {
|
|
wx.offAppShow(this._onAppShow)
|
|
} catch (e) {}
|
|
this._appShowBound = false
|
|
}
|
|
|
|
pause() {
|
|
if (!this._running || this._paused) return
|
|
this._paused = true
|
|
this._pausedTime = Date.now()
|
|
clearInterval(this._intervalId)
|
|
this._intervalId = null
|
|
}
|
|
|
|
resume() {
|
|
if (!this._running || !this._paused) return
|
|
this._startTime += Date.now() - this._pausedTime
|
|
this._paused = false
|
|
this._tick()
|
|
this._intervalId = setInterval(() => { this._tick() }, 1000)
|
|
}
|
|
|
|
stop() {
|
|
this._running = false
|
|
this._paused = false
|
|
clearInterval(this._intervalId)
|
|
this._intervalId = null
|
|
this._unbindAppShow()
|
|
return this._elapsed
|
|
}
|
|
|
|
_tick() {
|
|
if (!this._running || this._paused) return
|
|
this._elapsed = Math.floor((Date.now() - this._startTime) / 1000)
|
|
this._remaining = Math.max(0, this._duration - this._elapsed)
|
|
|
|
this.onTick({
|
|
remaining: this._remaining,
|
|
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 isGoalReached() { return this._goalReached }
|
|
get remaining() { return this._remaining }
|
|
get elapsed() { return this._elapsed }
|
|
}
|
|
|
|
module.exports = Timer
|