feat: 排行榜虚拟领奖台 + 勋章对齐与尺寸统一

- 领奖台数据不足 3 名时显示虚拟空位(虚线轮廓+虚位以待)
- 领奖台勋章与昵称对齐改为等高盒结构性修复(移除 margin 盲推)
- 全 app 成就勋章图标尺寸统一为 30rpx(前三名/列表/设置页/记录页一致)
- 勋章 SVG 按包围盒精确居中(utils/icons.js)
- 设置页/记录页勋章展示配套调整
This commit is contained in:
2026-08-04 11:23:48 +08:00
parent bce796d893
commit b52db87ddf
17 changed files with 690 additions and 67 deletions
+72 -2
View File
@@ -65,6 +65,37 @@ Page({
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
@@ -131,6 +162,17 @@ Page({
} 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 ? (() => {
@@ -147,6 +189,16 @@ Page({
} 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
@@ -168,6 +220,17 @@ Page({
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
@@ -181,7 +244,11 @@ Page({
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
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 {
@@ -206,7 +273,10 @@ Page({
const entry = {
rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '',
duration, durationText: util.formatDuration(duration), sessions,
subText: `${sessions}`
subText: `${sessions}`,
// 同耐力榜:本地兜底 entry 必须带 isMe,否则勋章不渲染
isMe: true,
...localBadgeFields
}
this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false })
} else {