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 {
+89 -27
View File
@@ -44,38 +44,75 @@
<!-- 耐力榜领奖台顶部文案:哲理注脚,点出"撑得越久不一定越好" -->
<view class="podium-slogan" wx:if="{{activePeriod === 'endurance'}}">{{podiumSlogan}}</view>
<!-- Podium: top 3 (only when there are at least 3 entries) -->
<view class="podium" wx:if="{{rankedList.length >= 3}}">
<!-- Podium: top 3 (shown whenever there is at least 1 entry; empty slots render as virtual placeholders) -->
<view class="podium" wx:if="{{rankedList.length >= 1}}">
<!-- 2nd -->
<view class="podium-slot podium-slot--second rank-item--enter" style="animation-delay: 60ms;">
<view class="podium-avatar-wrap">
<view class="avatar avatar--md {{rankedList[1].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[1].avatarUrl || icons.peopleFill}}" mode="{{rankedList[1].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--silver">2</view>
</view>
<text class="podium-name">{{rankedList[1].name}}</text>
<text class="podium-dur">{{rankedList[1].durationText}}</text>
<view class="podium-base podium-base--second"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[1].subDate}}</text><text class="podium-base__time">{{rankedList[1].subTime}}</text></block><text wx:else>{{rankedList[1].subText}}</text></view></view>
<block wx:if="{{rankedList[1]}}">
<view class="podium-avatar-wrap">
<view class="avatar avatar--md {{rankedList[1].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[1].avatarUrl || icons.peopleFill}}" mode="{{rankedList[1].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--silver">2</view>
</view>
<view class="podium-name-row">
<text class="podium-name">{{rankedList[1].name}}</text>
<image wx:if="{{rankedList[1].badgeIcon}}" class="podium-badge" src="{{rankedList[1].badgeIcon}}" mode="aspectFit" data-name="{{rankedList[1].badgeName}}" data-days="{{rankedList[1].badgeDays}}" bindtap="onBadgeTap"></image>
</view>
<text class="podium-dur">{{rankedList[1].durationText}}</text>
<view class="podium-base podium-base--second"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[1].subDate}}</text><text class="podium-base__time">{{rankedList[1].subTime}}</text></block><text wx:else>{{rankedList[1].subText}}</text></view></view>
</block>
<block wx:else>
<view class="podium-avatar-wrap">
<view class="avatar avatar--md podium-avatar--empty"><image class="avatar__img podium-avatar__img--empty" src="{{icons.peopleFill}}" mode="aspectFit"></image></view>
<view class="podium-medal podium-medal--silver">2</view>
</view>
<view class="podium-base podium-base--second"><view class="podium-base__label"><text class="podium-empty-label">虚位以待</text></view></view>
</block>
</view>
<!-- 1st -->
<view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;">
<image class="podium-crown" src="{{icons.crownFill}}" mode="aspectFit"></image>
<view class="podium-avatar-wrap">
<view class="avatar avatar--md avatar--lg {{rankedList[0].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[0].avatarUrl || icons.peopleFill}}" mode="{{rankedList[0].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--gold">1</view>
</view>
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
<view class="podium-base podium-base--first"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[0].subDate}}</text><text class="podium-base__time">{{rankedList[0].subTime}}</text></block><text wx:else>{{rankedList[0].subText}}</text></view></view>
<block wx:if="{{rankedList[0]}}">
<image class="podium-crown" src="{{icons.crownFill}}" mode="aspectFit"></image>
<view class="podium-avatar-wrap">
<view class="avatar avatar--md avatar--lg {{rankedList[0].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[0].avatarUrl || icons.peopleFill}}" mode="{{rankedList[0].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--gold">1</view>
</view>
<view class="podium-name-row">
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
<image wx:if="{{rankedList[0].badgeIcon}}" class="podium-badge podium-badge--first" src="{{rankedList[0].badgeIcon}}" mode="aspectFit" data-name="{{rankedList[0].badgeName}}" data-days="{{rankedList[0].badgeDays}}" bindtap="onBadgeTap"></image>
</view>
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
<view class="podium-base podium-base--first"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[0].subDate}}</text><text class="podium-base__time">{{rankedList[0].subTime}}</text></block><text wx:else>{{rankedList[0].subText}}</text></view></view>
</block>
<block wx:else>
<image class="podium-crown podium-crown--empty" src="{{icons.crownFill}}" mode="aspectFit"></image>
<view class="podium-avatar-wrap">
<view class="avatar avatar--md avatar--lg podium-avatar--empty"><image class="avatar__img podium-avatar__img--empty" src="{{icons.peopleFill}}" mode="aspectFit"></image></view>
<view class="podium-medal podium-medal--gold">1</view>
</view>
<view class="podium-base podium-base--first"><view class="podium-base__label"><text class="podium-empty-label">虚位以待</text></view></view>
</block>
</view>
<!-- 3rd -->
<view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;">
<view class="podium-avatar-wrap">
<view class="avatar avatar--md {{rankedList[2].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[2].avatarUrl || icons.peopleFill}}" mode="{{rankedList[2].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--bronze">3</view>
</view>
<text class="podium-name">{{rankedList[2].name}}</text>
<text class="podium-dur">{{rankedList[2].durationText}}</text>
<view class="podium-base podium-base--third"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[2].subDate}}</text><text class="podium-base__time">{{rankedList[2].subTime}}</text></block><text wx:else>{{rankedList[2].subText}}</text></view></view>
<block wx:if="{{rankedList[2]}}">
<view class="podium-avatar-wrap">
<view class="avatar avatar--md {{rankedList[2].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[2].avatarUrl || icons.peopleFill}}" mode="{{rankedList[2].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--bronze">3</view>
</view>
<view class="podium-name-row">
<text class="podium-name">{{rankedList[2].name}}</text>
<image wx:if="{{rankedList[2].badgeIcon}}" class="podium-badge" src="{{rankedList[2].badgeIcon}}" mode="aspectFit" data-name="{{rankedList[2].badgeName}}" data-days="{{rankedList[2].badgeDays}}" bindtap="onBadgeTap"></image>
</view>
<text class="podium-dur">{{rankedList[2].durationText}}</text>
<view class="podium-base podium-base--third"><view class="podium-base__label {{activePeriod === 'endurance' ? 'is-two' : ''}}"><block wx:if="{{activePeriod === 'endurance'}}"><text class="podium-base__date">{{rankedList[2].subDate}}</text><text class="podium-base__time">{{rankedList[2].subTime}}</text></block><text wx:else>{{rankedList[2].subText}}</text></view></view>
</block>
<block wx:else>
<view class="podium-avatar-wrap">
<view class="avatar avatar--md podium-avatar--empty"><image class="avatar__img podium-avatar__img--empty" src="{{icons.peopleFill}}" mode="aspectFit"></image></view>
<view class="podium-medal podium-medal--bronze">3</view>
</view>
<view class="podium-base podium-base--third"><view class="podium-base__label"><text class="podium-empty-label">虚位以待</text></view></view>
</block>
</view>
</view>
@@ -84,7 +121,7 @@
<view
wx:for="{{rankedList}}"
wx:key="rank"
wx:if="{{rankedList.length < 3 || index >= 3}}"
wx:if="{{index >= 3}}"
class="rank-item {{item.isMe ? 'is-me' : ''}} rank-item--enter"
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
>
@@ -93,7 +130,20 @@
</view>
<view class="avatar avatar--sm {{item.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{item.avatarUrl || icons.peopleFill}}" mode="{{item.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info">
<text class="rank-name">{{item.name}}</text>
<view class="rank-name-row">
<text class="rank-name">{{item.name}}</text>
<!-- 每个用户的勋章:云函数下发 totalDays,浅底行用主题色版。
无勋章(未达3天)或旧快照无 totalDays 时不渲染 -->
<image
wx:if="{{item.badgeIcon}}"
class="rank-badge"
src="{{item.badgeIcon}}"
mode="aspectFit"
data-name="{{item.badgeName}}"
data-days="{{item.badgeDays}}"
bindtap="onBadgeTap"
></image>
</view>
<text class="rank-sessions">{{item.subText}}</text>
</view>
<text class="rank-dur">{{item.durationText}}</text>
@@ -106,7 +156,19 @@
<view class="rank-num my-bar__num">{{myEntry.rank}}</view>
<view class="avatar avatar--sm my-bar__avatar {{myEntry.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{myEntry.avatarUrl || icons.peopleFill}}" mode="{{myEntry.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info">
<text class="rank-name">我 ({{myEntry.name}})</text>
<view class="rank-name-row">
<text class="rank-name">我 ({{myEntry.name}})</text>
<!-- 我的勋章:主题渐变底用白版 -->
<image
wx:if="{{myEntry.badgeIconWhite}}"
class="rank-badge"
src="{{myEntry.badgeIconWhite}}"
mode="aspectFit"
data-name="{{myEntry.badgeName}}"
data-days="{{myEntry.badgeDays}}"
bindtap="onBadgeTap"
></image>
</view>
<text class="rank-sessions">{{myEntry.subText}}</text>
</view>
<text class="rank-dur">{{myEntry.durationText}}</text>
+76 -6
View File
@@ -194,16 +194,42 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
/* 用 name 的 margin-top 直接控制与上方头像/角标的间距:它是 flex 子元素,
margin 必定生效不折叠,不依赖 avatar margin-bottom 的 collapse 行为
(WebView/Skyline 下不一致,曾导致角标压住昵称)。 */
margin-top: 30rpx;
/* 关键对齐修复:行盒高度设为与勋章等高(30rpx)。这样 flex 的 align-items:center
对齐的是"昵称盒中心"与"勋章盒中心"两个等高盒,而非 line-height:1 下偏矮、
且中文 glyph 视觉重心偏下的行盒,图标不会再飘在昵称上方。 */
line-height: 30rpx;
}
.podium-name--first {
font-size: 28rpx;
font-weight: 600;
line-height: 30rpx; /* 与首名勋章统一为 30rpx,前三名勋章等大且和昵称盒等高对齐 */
}
/* 领奖台名字 + 勋章横排(居中)。名字保留自身 max-width/省略,勋章固定不挤压 */
.podium-name-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6rpx;
max-width: 220rpx;
/* 原间距 margin-top:30rpx 从 .podium-name 移到此处:避免在 flex 子项上用
cross 轴 margin 干扰昵称与勋章的居中(那是之前图标"偏高"的隐藏元凶)。 */
margin-top: 30rpx;
}
.podium-badge {
width: 30rpx;
height: 30rpx;
flex-shrink: 0;
/* 不再用 margin-top 盲推:昵称行盒已设为与勋章等高(30rpx),flex align-items:center
直接对齐两盒中心,图标与昵称视觉对齐。若个别冠类勋章因图形重心仍有 ±1~2rpx
偏差,再单独给该图加 transform: translateY(2rpx) 即可,不要回退到 margin 推法。 */
}
.podium-badge--first {
width: 30rpx;
height: 30rpx;
}
/* session count printed on the podium base. Pinned a fixed offset above the
@@ -243,7 +269,8 @@
font-size: 24rpx;
font-weight: 600;
color: var(--text-secondary);
margin-top: 2rpx;
/* line-height 收紧后行盒缩短约 8rpx,此处补偿保持与名字的视觉间距 */
margin-top: 10rpx;
font-variant-numeric: tabular-nums;
}
@@ -277,6 +304,30 @@
box-shadow: 0 6rpx 18rpx rgba(212, 107, 8, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
}
/* 虚拟领奖台:空位占位(名单不足 3 名时,空缺名次渲染成"虚位以待") */
.podium-avatar--empty {
opacity: 1;
border: 4rpx dashed rgba(var(--primary-rgb), 0.35);
box-shadow: none;
background: rgba(var(--primary-rgb), 0.04);
}
.podium-avatar__img--empty {
opacity: 0.26;
filter: grayscale(1);
}
.podium-empty-label {
font-size: 22rpx;
font-weight: 500;
color: var(--text-secondary);
opacity: 0.5;
}
.podium-crown--empty {
opacity: 0.3;
}
/* Rank list */
.rank-list {
padding: 16rpx 24rpx;
@@ -340,6 +391,25 @@
white-space: nowrap;
}
/* 昵称 + 最新勋章横排:昵称可省略,勋章固定不挤压 */
.rank-name-row {
display: flex;
align-items: center;
gap: 6rpx;
min-width: 0;
}
.rank-name-row .rank-name {
flex: 0 1 auto;
min-width: 0;
}
.rank-badge {
width: 30rpx;
height: 30rpx;
flex-shrink: 0;
}
.rank-sessions {
font-size: 22rpx;
color: var(--text-secondary);