const themeMod = require('../../utils/theme') const iconsMod = require('../../utils/icons') const util = require('../../utils/util') const storage = require('../../utils/storage') const config = require('../../config') Page({ data: { theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' }, themeStyle: themeMod.BASE_VARS, periods: [ { key: 'day', label: '日榜' }, { key: 'month', label: '月榜' }, { key: 'year', label: '年榜' } ], activePeriod: 'day', rankedList: [], myEntry: null, loading: false, refreshing: false, empty: false, icons: iconsMod.build() }, onLoad() { themeMod.applyThemeToPage(this) this._firstShow = true }, onShow() { themeMod.applyThemeToPage(this) const theme = themeMod.getCurrentTheme() this.setData({ theme, icons: iconsMod.build(theme) }) try { const tb = this.getTabBar() if (tb) tb.setData({ selected: 2 }) } catch (e) {} if (this._firstShow) { this._firstShow = false this.fetchRank() return } this.fetchRank() }, onPullDownRefresh() { this.fetchRank(() => wx.stopPullDownRefresh()) }, switchPeriod(e) { const period = e.currentTarget.dataset.period if (period === this.data.activePeriod) return this.setData({ activePeriod: period, rankedList: [], myEntry: null }) this.fetchRank() }, fetchRank(cb) { // 序列号:快速切换周期时丢弃旧响应,避免旧 period 的数据覆盖新 period this._fetchSeq = (this._fetchSeq || 0) + 1 const seq = this._fetchSeq const period = this.data.activePeriod // 乐观缓存:命中该 period 的近期缓存就先秒显旧数据,后台再静默拉新。 // 命中时不进 loading 骨架屏(避免旧数据被空白覆盖),用 refreshing 标记 // 后台刷新状态;未命中才显示骨架屏。云函数全表扫描冷启动可达 2-3s, // 这层客户端缓存把"重复进入 / 切 period"的等待几乎降到无感。 const cached = storage.getLeaderboardCache(period) if (cached && cached.rankedList) { this.setData({ rankedList: cached.rankedList, myEntry: cached.myEntry, loading: false, empty: cached.empty, refreshing: true }) } else { this.setData({ loading: true, empty: false, refreshing: false }) } wx.cloud.callFunction({ name: 'leaderboard', data: { period, maxRank: config.leaderboardMaxRank } }).then(res => { if (seq !== this._fetchSeq) { if (cb) cb(); return } // 过期响应,丢弃 this._applyResult(res.result || {}) if (cb) cb() }).catch((err) => { // Cloud function not deployed or network error — fall back to // local data silently. No toast here: empty day-leaderboard is // normal (user didn't train today), and a toast would mislead // them into thinking the cloud is broken. console.warn('[leaderboard] cloud function failed, using local fallback:', err) if (seq !== this._fetchSeq) { if (cb) cb(); return } // 已有缓存秒显时后台刷新失败:保持旧数据,不打断用户;仅无缓存才走本地兜底。 if (cached && cached.rankedList) { this.setData({ refreshing: false }) } else { this._applyLocal() } if (cb) cb() }) }, _applyResult(result) { const list = (result.ranked || []).map(item => ({ ...item, durationText: util.formatDuration(item.duration) })) const myEntry = result.myEntry ? { ...result.myEntry, durationText: util.formatDuration(result.myEntry.duration), isMe: true } : null const empty = list.length === 0 && !myEntry this.setData({ rankedList: list, myEntry, loading: false, refreshing: false, empty }) // 写入乐观缓存:下次进页面 / 切 period 秒显 storage.setLeaderboardCache(this.data.activePeriod, { rankedList: list, myEntry, empty }) }, /** Local fallback: use local storage when cloud function isn't deployed */ _applyLocal() { const period = this.data.activePeriod const allRecords = Object.values(storage.getRecords()).flat() const today = storage.getToday() const thisMonth = today.substring(0, 7) const thisYear = String(new Date().getFullYear()) const profile = storage.getProfile() const localName = profile.nickname || '我' let duration = 0 let sessions = 0 allRecords.forEach(r => { if (period === 'day' && util.dateOnly(r.date) === util.dateOnly(today)) { duration += r.duration; sessions++ } if (period === 'month' && r.date.startsWith(thisMonth)) { duration += r.duration; sessions++ } if (period === 'year' && r.date.startsWith(thisYear)) { duration += r.duration; sessions++ } }) if (duration > 0) { const entry = { rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '', duration, durationText: util.formatDuration(duration), sessions } this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false }) } else { this.setData({ loading: false, refreshing: false, empty: true }) } }, /** * 分享给朋友 — 排行榜页。把用户当前名次写进标题,有"攀比"属性, * 分享欲望会强一些。 */ onShareAppMessage() { const me = this.data.myEntry const s = config.share.leaderboard // myEntry.rank 依赖云函数返回,缺失时回退静态 title,避免出现"排第 undefined" if (me && me.rank) { const periodLabel = { day: '日', month: '月', year: '年' }[this.data.activePeriod] || '' return { title: s.titleTemplate.replace('{period}', periodLabel).replace('{rank}', me.rank), path: s.path } } return { title: s.title, path: s.path } }, /** * 分享到朋友圈。 */ onShareTimeline() { return { title: config.share.timelineTitle } } })