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: '日榜', maxRank: config.leaderboardMaxRank }, { key: 'month', label: '月榜', maxRank: config.leaderboardMaxRank }, { key: 'year', label: '年榜', maxRank: config.leaderboardMaxRank }, { key: 'endurance', label: '耐力', maxRank: 10 } ], activePeriod: 'day', podiumSlogan: config.leaderboardPodiumSlogan, rankedList: [], myEntry: null, loading: false, refreshing: false, pullRefreshing: 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) {} // 训练完设的强刷标志:下次进排行榜跳过云函数缓存,立刻拿到含新记录的数据 let force = false try { if (wx.getStorageSync('_lb_force_refresh')) { force = true wx.removeStorageSync('_lb_force_refresh') } } catch (e) {} this._firstShow = false this.fetchRank(undefined, force) }, onPullDownRefresh() { this.setData({ pullRefreshing: true }) this.fetchRank(() => { this.setData({ pullRefreshing: false }) wx.stopPullDownRefresh() }, true) }, switchPeriod(e) { const period = e.currentTarget.dataset.period if (period === this.data.activePeriod) return this.setData({ activePeriod: period, rankedList: [], myEntry: null }) this.fetchRank() }, /** * totalDays → 最新解锁勋章(trainingDayBadges 按天数递增,取 days <= totalDays 最后一项)。 * 返回 badge 定义(含 days/name/icon),无解锁或异常返回 null。 * 云函数路径:item.totalDays 是云端算的(全榜通用,别人的行也能显示); * 本地兜底:用 storage 本地 totalDays 算自己那条。 */ _badgeOf(totalDays) { try { const days = Math.max(0, Number(totalDays) || 0) const badges = config.trainingDayBadges || [] let latest = null for (const b of badges) { if (days >= b.days) latest = b } return latest } catch (e) { return null } }, /** 点击勋章:toast 展示勋章名与解锁天数门槛 */ onBadgeTap(e) { const badge = e.currentTarget.dataset if (!badge || !badge.name) return wx.showToast({ title: badge.name + ' · 累计 ' + badge.days + ' 天解锁', icon: 'none', duration: 1800 }) }, fetchRank(cb, force) { // 序列号:快速切换周期时丢弃旧响应,避免旧 period 的数据覆盖新 period this._fetchSeq = (this._fetchSeq || 0) + 1 const seq = this._fetchSeq const period = this.data.activePeriod const periodObj = this.data.periods.find(p => p.key === period) const maxRank = (periodObj && periodObj.maxRank) || config.leaderboardMaxRank // 乐观缓存:命中该 period 的近期缓存就先秒显旧数据,后台再静默拉新。 // 命中时不进 loading 骨架屏(避免旧数据被空白覆盖),用 refreshing 标记 // 后台刷新状态;未命中才显示骨架屏。云函数现直接读预计算快照(1 次 get≈50ms), // 网络往返本身已极快;这层客户端缓存进一步把"重复进入 / 切 period / 弱网"的 // 等待降到无感,并兜底离线。(训练后带 force 的那次才会实时重算并回写快照。) 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, force: !!force } }).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. 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 isEndurance = this.data.activePeriod === 'endurance' const list = (result.ranked || []).map(item => { const base = { ...item, durationText: util.formatDuration(item.duration) } if (isEndurance) { const { date, time } = this._splitBestDate(item.bestDate) base.subText = this._formatBestDate(item.bestDate) base.subDate = date base.subTime = time } else { base.subText = `${item.sessions}次` } // 勋章:云函数下发每个用户的 totalDays,映射最新解锁勋章(全榜可见)。 // 浅底行用主题色版 iconFill;旧快照无 totalDays 时 badge 为 null,不渲染。 // 注意:badgeIcon 必须赋图标 data URI(从 icons 映射查),不能赋字符串 key, // 否则 WXML src 会当成路径加载报 500。 const badge = this._badgeOf(item.totalDays) if (badge) { const icons = this.data.icons || iconsMod.build() base.badgeIcon = icons[badge.icon + 'Fill'] base.badgeName = badge.name base.badgeDays = badge.days } return base }) const myEntry = result.myEntry ? (() => { const base = { ...result.myEntry, durationText: util.formatDuration(result.myEntry.duration), isMe: true } if (isEndurance) { const { date, time } = this._splitBestDate(result.myEntry.bestDate) base.subText = this._formatBestDate(result.myEntry.bestDate) base.subDate = date base.subTime = time } else { base.subText = `${result.myEntry.sessions}次` } // my-bar 是主题渐变底,需要白版图标;badgeIcon(主题色)给 is-me 行用。 // 同样必须赋 data URI,不能赋字符串 key。 const badge = this._badgeOf(result.myEntry.totalDays) if (badge) { const icons = this.data.icons || iconsMod.build() base.badgeIcon = icons[badge.icon + 'Fill'] base.badgeIconWhite = icons[badge.icon + 'White'] base.badgeName = badge.name base.badgeDays = badge.days } return base })() : 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 profile = storage.getProfile() const localName = profile.nickname || '我' // 本地兜底勋章:从本地记录算去重训练日(与云函数 totalDays 口径一致), // 映射最新解锁勋章。云函数不可用时自己的行/my-bar 仍能显示勋章。 // 注意:badgeIcon 必须是 data URI(从 icons 映射查),不能是字符串 key。 const trainedDays = new Set() allRecords.forEach(r => { if (r && r.date) trainedDays.add(util.dateOnly(r.date)) }) const localBadge = this._badgeOf(trainedDays.size) const _icons = this.data.icons || iconsMod.build() const localBadgeFields = localBadge ? { badgeIcon: _icons[localBadge.icon + 'Fill'], badgeIconWhite: _icons[localBadge.icon + 'White'], badgeName: localBadge.name, badgeDays: localBadge.days } : {} // 耐力榜本地兜底:取本地全部记录里单次最久 + 其日期 if (period === 'endurance') { let bestDuration = 0 let bestDate = '' allRecords.forEach(r => { const dur = Number(r.duration) || 0 if (dur > bestDuration) { bestDuration = dur; bestDate = r.date } }) if (bestDuration > 0) { const { date, time } = this._splitBestDate(bestDate) const entry = { rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '', duration: bestDuration, durationText: util.formatDuration(bestDuration), bestDate, subText: this._formatBestDate(bestDate), subDate: date, subTime: time, // 本地兜底也要 isMe:否则 is-me 行不渲染勋章、my-bar 条件(rank>length)又不满足, // 勋章在弱网/云函数故障时完全不可见 isMe: true, ...localBadgeFields } this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false }) } else { this.setData({ loading: false, refreshing: false, empty: true }) } return } const today = storage.getToday() const thisMonth = today.substring(0, 7) const thisYear = String(new Date().getFullYear()) 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, subText: `${sessions}次`, // 同耐力榜:本地兜底 entry 必须带 isMe,否则勋章不渲染 isMe: true, ...localBadgeFields } this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false }) } else { this.setData({ loading: false, refreshing: false, empty: true }) } }, /** * 把记录里的 "YYYY-MM-DD HH:MM:SS" 截成 "YYYY-MM-DD HH:MM"(去掉秒), * 纯日期串原样返回。耐力榜用其展示单次的"产生时间"。 */ _formatBestDate(s) { if (!s || typeof s !== 'string') return '' if (s.length >= 16) return s.substring(0, 16) return s }, /** * 把 "YYYY-MM-DD HH:MM"(来自 _formatBestDate)拆成 * { date: 'YYYY-MM-DD', time: 'HH:MM' },供领奖台底座两行展示耐力榜的"单次日期"。 */ _splitBestDate(s) { const f = this._formatBestDate(s) if (f && f.length >= 11) return { date: f.substring(0, 10), time: f.substring(11) } return { date: f || '', time: '' } }, /** * 分享给朋友 — 排行榜页。把用户当前名次写进标题,有"攀比"属性, * 分享欲望会强一些。 */ onShareAppMessage() { const me = this.data.myEntry const s = config.share.leaderboard // myEntry.rank 依赖云函数返回,缺失时回退静态 title,避免出现"排第 undefined" if (me && me.rank) { const periodLabel = { day: '日', month: '月', year: '年', endurance: '耐力' }[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 } } })