diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 494a637..9e7db53 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -149,6 +149,37 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => { for (const doc of latestByOpenid.values()) { const openid = doc._openid || 'unknown' const records = doc.records || {} + + if (period === 'endurance') { + // 全局单次最久:遍历该用户全部月份的 records,取 max(duration) 那条, + // 并记下其 r.date 作为"产生时间"。不做日/月/年过滤、不累加。 + let bestDuration = 0 + let bestDate = '' + let total = 0 + for (const monthKey of Object.keys(records)) { + const arr = records[monthKey] + if (!Array.isArray(arr)) continue + for (const r of arr) { + if (!r || typeof r.date !== 'string') continue + total++ + const dur = Number(r.duration) || 0 + if (dur > bestDuration) { bestDuration = dur; bestDate = r.date } + } + } + if (bestDuration > 0) { + const profile = doc.profile || {} + userMap.set(openid, { + openid, + duration: bestDuration, + bestDate, + sessions: total, + nickname: profile.nickname || '', + avatarUrl: profile.avatarUrl || '' + }) + } + continue + } + let duration = 0 let sessions = 0 @@ -208,6 +239,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => { name: _displayName(item), duration: item.duration, sessions: item.sessions, + bestDate: item.bestDate || '', avatarUrl: item.avatarUrl || '' })) @@ -231,6 +263,7 @@ const _buildFromScan = async (latestByOpenid, period, limit, myOpenid) => { name: _displayName(myEntryRaw), duration: myEntryRaw.duration, sessions: myEntryRaw.sessions, + bestDate: myEntryRaw.bestDate || '', avatarUrl: myEntryRaw.avatarUrl || '' } : null @@ -294,7 +327,7 @@ const _serveFromSnapshot = async (period, limit, myOpenid) => { */ const _rebuildSnapshots = async () => { const latestByOpenid = await _fetchLatestByOpenid(true) - for (const p of ['day', 'month', 'year']) { + for (const p of ['day', 'month', 'year', 'endurance']) { const result = await _buildFromScan(latestByOpenid, p, SNAPSHOT_TOP, null) console.log('[leaderboard] rebuilt', p, 'rankedLen=', (result && result.ranked) ? result.ranked.length : 'undef') await _persistSnapshot(p, result) @@ -328,7 +361,7 @@ exports.main = async (event) => { const { period, maxRank, force } = event || {} const limit = Math.max(1, Math.min(parseInt(maxRank) || DEFAULT_MAX_RANK, 500)) if (!period) return { err: 'missing period' } - if (period !== 'day' && period !== 'month' && period !== 'year') return { err: 'invalid period' } + if (period !== 'day' && period !== 'month' && period !== 'year' && period !== 'endurance') return { err: 'invalid period' } const myOpenid = cloud.getWXContext().OPENID diff --git a/config.js b/config.js index 0750af3..4bf3f74 100644 --- a/config.js +++ b/config.js @@ -7,7 +7,7 @@ module.exports = { version: 'v3.02', /** 最后更新日期,外显在设置页「关于」 */ - updatedAt: '2026-07-29', + updatedAt: '2026-07-31', /** 开发者名称 / 微信号,设置页点击可复制 */ developer: '三口一瓶', @@ -21,6 +21,12 @@ module.exports = { /** 排行榜最大显示人数 */ leaderboardMaxRank: 50, + /** + * 耐力榜领奖台顶部文案。哲理注脚,点出"撑得越久不一定越好"的价值观, + * 与耐力榜(比谁单次撑最久)形成反差。仅耐力榜领奖台顶部显示,改这里即可。 + */ + leaderboardPodiumSlogan: '平板支撑不是一场和时间的恋爱。撑得越久,不一定越好;真正重要的,是每一秒都没有辜负身体。', + /** * 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。 * 设置页可选男声/女声,客户端按选择取下面对应的一组传给 tts 云函数。 diff --git a/pages/leaderboard/leaderboard.js b/pages/leaderboard/leaderboard.js index 4f56ed4..38b8942 100644 --- a/pages/leaderboard/leaderboard.js +++ b/pages/leaderboard/leaderboard.js @@ -9,11 +9,13 @@ Page({ 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: '年榜' } + { 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, @@ -68,6 +70,8 @@ Page({ 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 标记 @@ -91,7 +95,7 @@ Page({ name: 'leaderboard', data: { period, - maxRank: config.leaderboardMaxRank, + maxRank, force: !!force } }).then(res => { @@ -116,15 +120,35 @@ Page({ }, _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 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}次` + } + 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}次` + } + return base + })() : null const empty = list.length === 0 && !myEntry this.setData({ rankedList: list, @@ -141,11 +165,34 @@ Page({ _applyLocal() { const period = this.data.activePeriod const allRecords = Object.values(storage.getRecords()).flat() + const profile = storage.getProfile() + const localName = profile.nickname || '我' + + // 耐力榜本地兜底:取本地全部记录里单次最久 + 其日期 + 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 + } + 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()) - const profile = storage.getProfile() - const localName = profile.nickname || '我' let duration = 0 let sessions = 0 @@ -166,6 +213,26 @@ Page({ } }, + /** + * 把记录里的 "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: '' } + }, + /** * 分享给朋友 — 排行榜页。把用户当前名次写进标题,有"攀比"属性, * 分享欲望会强一些。 @@ -175,7 +242,7 @@ Page({ const s = config.share.leaderboard // myEntry.rank 依赖云函数返回,缺失时回退静态 title,避免出现"排第 undefined" if (me && me.rank) { - const periodLabel = { day: '日', month: '月', year: '年' }[this.data.activePeriod] || '' + const periodLabel = { day: '日', month: '月', year: '年', endurance: '耐力' }[this.data.activePeriod] || '' return { title: s.titleTemplate.replace('{period}', periodLabel).replace('{rank}', me.rank), path: s.path diff --git a/pages/leaderboard/leaderboard.wxml b/pages/leaderboard/leaderboard.wxml index c403a64..20ffb7b 100644 --- a/pages/leaderboard/leaderboard.wxml +++ b/pages/leaderboard/leaderboard.wxml @@ -41,6 +41,9 @@ + + {{podiumSlogan}} + @@ -51,7 +54,7 @@ {{rankedList[1].name}} {{rankedList[1].durationText}} - {{rankedList[1].sessions}}次 + {{rankedList[1].subDate}}{{rankedList[1].subTime}}{{rankedList[1].subText}} @@ -62,7 +65,7 @@ {{rankedList[0].name}} {{rankedList[0].durationText}} - {{rankedList[0].sessions}}次 + {{rankedList[0].subDate}}{{rankedList[0].subTime}}{{rankedList[0].subText}} @@ -72,7 +75,7 @@ {{rankedList[2].name}} {{rankedList[2].durationText}} - {{rankedList[2].sessions}}次 + {{rankedList[2].subDate}}{{rankedList[2].subTime}}{{rankedList[2].subText}} @@ -91,7 +94,7 @@ {{item.name}} - {{item.sessions}}次 + {{item.subText}} {{item.durationText}} @@ -104,7 +107,7 @@ 我 ({{myEntry.name}}) - {{myEntry.sessions}}次 + {{myEntry.subText}} {{myEntry.durationText}} diff --git a/pages/leaderboard/leaderboard.wxss b/pages/leaderboard/leaderboard.wxss index 80a1947..b470c9b 100644 --- a/pages/leaderboard/leaderboard.wxss +++ b/pages/leaderboard/leaderboard.wxss @@ -69,6 +69,23 @@ opacity: 0.6; } +/* 耐力榜领奖台顶部文案:哲理注脚,主色呼应 + 金句卡片质感 */ +.podium-slogan { + position: relative; + margin: 8rpx 32rpx 4rpx; + padding: 22rpx 28rpx 24rpx; + text-align: center; + font-size: 24rpx; + line-height: 1.65; + color: var(--text); + font-weight: 500; + font-style: italic; + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.03)); + border: 1rpx solid rgba(var(--primary-rgb), 0.16); + border-radius: 18rpx; + box-shadow: 0 4rpx 14rpx rgba(var(--primary-rgb), 0.10); +} + /* ---- podium (top 3) ---- */ .podium { display: flex; @@ -194,14 +211,32 @@ stepped base heights (the podium itself is bottom-aligned via flex-end). */ .podium-base__label { position: absolute; - left: 0; - right: 0; - bottom: 10rpx; + left: 4rpx; + right: 4rpx; + bottom: 6rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; font-size: 22rpx; font-weight: 700; color: var(--text); - line-height: 1; + line-height: 1.15; +} + +/* 耐力榜副标签(单次日期)在领奖台底座上拆成「日期 + 时间」两行。 + 缩小字号 + 抬高最矮的第 3 名底座(48→64rpx),三阶统一为 96/80/64, + 让两行文字在各级底座内都垂直居中、不贴顶(含微信字体缩放余量)。 */ +.podium-base__date { + font-size: 18rpx; + line-height: 1.15; +} +.podium-base__time { + font-size: 16rpx; + line-height: 1.1; + opacity: 0.85; + margin-top: 2rpx; } .podium-dur { @@ -232,12 +267,12 @@ box-shadow: 0 6rpx 18rpx rgba(250, 140, 22, 0.30), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45); } .podium-base--second { - height: 64rpx; + height: 80rpx; background: linear-gradient(180deg, rgba(201, 205, 212, 0.50), rgba(140, 140, 140, 0.16)); box-shadow: 0 6rpx 18rpx rgba(140, 140, 140, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45); } .podium-base--third { - height: 48rpx; + height: 64rpx; background: linear-gradient(180deg, rgba(232, 160, 106, 0.50), rgba(212, 107, 8, 0.16)); box-shadow: 0 6rpx 18rpx rgba(212, 107, 8, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45); }