425 lines
9.1 KiB
Vue
425 lines
9.1 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="bg-decoration"></view>
|
||
|
||
<!-- Tab 切换 -->
|
||
<view class="tab-bar glass-card">
|
||
<view
|
||
class="tab-item"
|
||
:class="{ active: gameType === 'minesweeper' }"
|
||
@tap="switchTab('minesweeper')"
|
||
>
|
||
<text class="tab-text">付费模式</text>
|
||
</view>
|
||
<view
|
||
class="tab-item"
|
||
:class="{ active: gameType === 'minesweeper_free' }"
|
||
@tap="switchTab('minesweeper_free')"
|
||
>
|
||
<text class="tab-text">免费模式</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 我的排名 -->
|
||
<view class="my-card glass-card" v-if="myRank">
|
||
<view class="my-left">
|
||
<text class="my-label">我的排名</text>
|
||
<text class="my-rank">{{ myRank.rank ? `#${myRank.rank}` : '未上榜' }}</text>
|
||
</view>
|
||
<view class="my-divider"></view>
|
||
<view class="my-stats">
|
||
<view class="stat-col">
|
||
<text class="stat-val">{{ myRank.wins || 0 }}</text>
|
||
<text class="stat-key">胜场</text>
|
||
</view>
|
||
<view class="stat-col">
|
||
<text class="stat-val">{{ myRank.matches_played || 0 }}</text>
|
||
<text class="stat-key">总场次</text>
|
||
</view>
|
||
<view class="stat-col">
|
||
<text class="stat-val">{{ formatWinRate(myRank.win_rate) }}</text>
|
||
<text class="stat-key">胜率</text>
|
||
</view>
|
||
</view>
|
||
<view class="my-divider"></view>
|
||
<view class="my-right">
|
||
<text class="my-pts">{{ myRank.total_rank_points || 0 }}</text>
|
||
<text class="my-pts-label">积分</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 榜单 -->
|
||
<scroll-view scroll-y class="list-wrap" @scrolltolower="loadMore">
|
||
|
||
<view v-if="loading && list.length === 0" class="state-box">
|
||
<text class="state-icon">⏳</text>
|
||
<text class="state-text">加载中...</text>
|
||
</view>
|
||
|
||
<view v-else-if="!loading && list.length === 0" class="state-box">
|
||
<text class="state-icon">🏆</text>
|
||
<text class="state-text">暂无数据,快来挑战吧!</text>
|
||
</view>
|
||
|
||
<view v-else class="list">
|
||
<view
|
||
v-for="item in list"
|
||
:key="item.user_id"
|
||
class="rank-row glass-card"
|
||
:class="{ 'is-me': item.user_id === myUserId }"
|
||
>
|
||
<view class="rank-badge">
|
||
<text v-if="item.rank === 1" class="medal">🥇</text>
|
||
<text v-else-if="item.rank === 2" class="medal">🥈</text>
|
||
<text v-else-if="item.rank === 3" class="medal">🥉</text>
|
||
<text v-else class="rank-no">{{ item.rank }}</text>
|
||
</view>
|
||
|
||
<image class="avatar" :src="item.avatar || fallback" mode="aspectFill" />
|
||
|
||
<view class="item-info">
|
||
<view class="name-row">
|
||
<text class="item-name">{{ item.nickname || '匿名玩家' }}</text>
|
||
<text v-if="item.user_id === myUserId" class="me-tag">我</text>
|
||
</view>
|
||
<text class="item-sub">{{ item.wins }}胜 · {{ item.matches_played }}场 · {{ formatWinRate(item.win_rate) }}胜率</text>
|
||
</view>
|
||
|
||
<view class="pts-box">
|
||
<text class="pts-val">{{ item.total_rank_points }}</text>
|
||
<text class="pts-unit">分</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="loadingMore" class="footer-tip"><text class="footer-txt">加载中...</text></view>
|
||
<view v-if="!hasMore && list.length > 0" class="footer-tip"><text class="footer-txt">— 已显示全部 —</text></view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { authRequest } from '../../../utils/request.js'
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
gameType: 'minesweeper',
|
||
list: [],
|
||
myRank: null,
|
||
myUserId: null,
|
||
total: 0,
|
||
page: 1,
|
||
pageSize: 20,
|
||
loading: false,
|
||
loadingMore: false,
|
||
hasMore: true,
|
||
fallback: 'https://via.placeholder.com/80/FF6B00/FFFFFF?text=U',
|
||
}
|
||
},
|
||
onLoad() {
|
||
const info = uni.getStorageSync('user_info') || {}
|
||
this.myUserId = info.id || info.user_id || null
|
||
this.fetchList(true)
|
||
},
|
||
methods: {
|
||
switchTab(type) {
|
||
if (this.gameType === type) return
|
||
this.gameType = type
|
||
this.fetchList(true)
|
||
},
|
||
async fetchList(reset = false) {
|
||
if (reset) {
|
||
this.list = []
|
||
this.page = 1
|
||
this.hasMore = true
|
||
this.myRank = null
|
||
this.loading = true
|
||
} else {
|
||
if (!this.hasMore || this.loadingMore) return
|
||
this.loadingMore = true
|
||
}
|
||
try {
|
||
const res = await authRequest({
|
||
url: '/api/app/games/leaderboard',
|
||
method: 'GET',
|
||
data: { game_type: this.gameType, page: this.page, page_size: this.pageSize },
|
||
})
|
||
const items = res.list || []
|
||
this.list = reset ? items : [...this.list, ...items]
|
||
this.total = res.total || 0
|
||
this.myRank = res.me || null
|
||
this.hasMore = this.list.length < this.total
|
||
if (this.hasMore) this.page++
|
||
} catch {
|
||
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
|
||
} finally {
|
||
this.loading = false
|
||
this.loadingMore = false
|
||
}
|
||
},
|
||
loadMore() { this.fetchList(false) },
|
||
formatWinRate(rate) {
|
||
if (rate == null) return '0%'
|
||
return `${(rate * 100).toFixed(1)}%`
|
||
},
|
||
},
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import '@/uni.scss';
|
||
|
||
.page {
|
||
min-height: 100vh;
|
||
background: $bg-page;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Tab */
|
||
.tab-bar {
|
||
position: relative;
|
||
z-index: 10;
|
||
display: flex;
|
||
margin: 24rpx 32rpx 20rpx;
|
||
padding: 8rpx;
|
||
border-radius: $radius-round;
|
||
}
|
||
|
||
.tab-item {
|
||
flex: 1;
|
||
height: 72rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: $radius-round;
|
||
transition: all $transition-normal $ease-out;
|
||
|
||
&.active {
|
||
background: $gradient-brand;
|
||
box-shadow: $shadow-warm;
|
||
.tab-text { color: #fff; font-weight: 700; }
|
||
}
|
||
}
|
||
|
||
.tab-text {
|
||
font-size: $font-md;
|
||
color: $text-sub;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 我的排名 */
|
||
.my-card {
|
||
position: relative;
|
||
z-index: 5;
|
||
margin: 0 32rpx 24rpx;
|
||
padding: 28rpx 24rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
}
|
||
|
||
.my-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
min-width: 100rpx;
|
||
}
|
||
|
||
.my-label {
|
||
font-size: $font-xs;
|
||
color: $text-sub;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.my-rank {
|
||
font-size: 38rpx;
|
||
font-weight: 900;
|
||
color: $brand-primary;
|
||
}
|
||
|
||
.my-divider {
|
||
width: 1px;
|
||
height: 60rpx;
|
||
background: $border-color-light;
|
||
margin: 0 20rpx;
|
||
}
|
||
|
||
.my-stats {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-around;
|
||
}
|
||
|
||
.stat-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
}
|
||
|
||
.stat-val {
|
||
font-size: 30rpx;
|
||
font-weight: 800;
|
||
color: $text-main;
|
||
}
|
||
|
||
.stat-key {
|
||
font-size: $font-xs;
|
||
color: $text-sub;
|
||
}
|
||
|
||
.my-right {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
min-width: 80rpx;
|
||
}
|
||
|
||
.my-pts {
|
||
font-size: 38rpx;
|
||
font-weight: 900;
|
||
color: $brand-primary;
|
||
line-height: 1;
|
||
}
|
||
|
||
.my-pts-label {
|
||
font-size: $font-xs;
|
||
color: $text-sub;
|
||
margin-top: 6rpx;
|
||
}
|
||
|
||
/* 列表 */
|
||
.list-wrap {
|
||
flex: 1;
|
||
padding: 0 32rpx;
|
||
}
|
||
|
||
.state-box {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 120rpx 0;
|
||
gap: 20rpx;
|
||
}
|
||
|
||
.state-icon { font-size: 80rpx; }
|
||
.state-text { font-size: $font-md; color: $text-sub; }
|
||
|
||
.list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.rank-row {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 20rpx 24rpx;
|
||
gap: 20rpx;
|
||
|
||
&.is-me {
|
||
border: 2rpx solid rgba($brand-primary, 0.4);
|
||
background: linear-gradient(135deg, rgba($brand-primary, 0.06) 0%, $bg-glass 100%);
|
||
}
|
||
}
|
||
|
||
.rank-badge {
|
||
width: 52rpx;
|
||
height: 52rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.medal { font-size: 44rpx; }
|
||
|
||
.rank-no {
|
||
font-size: $font-md;
|
||
font-weight: 800;
|
||
color: $text-sub;
|
||
}
|
||
|
||
.avatar {
|
||
width: 88rpx;
|
||
height: 88rpx;
|
||
border-radius: 50%;
|
||
background: $bg-secondary;
|
||
flex-shrink: 0;
|
||
border: 2rpx solid $border-color-light;
|
||
}
|
||
|
||
.item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.name-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.item-name {
|
||
font-size: $font-lg;
|
||
font-weight: 700;
|
||
color: $text-main;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 220rpx;
|
||
}
|
||
|
||
.me-tag {
|
||
font-size: $font-xs;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
background: $gradient-brand;
|
||
padding: 2rpx 14rpx;
|
||
border-radius: $radius-round;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.item-sub {
|
||
font-size: $font-sm;
|
||
color: $text-sub;
|
||
}
|
||
|
||
.pts-box {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 4rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.pts-val {
|
||
font-size: 36rpx;
|
||
font-weight: 900;
|
||
color: $brand-primary;
|
||
}
|
||
|
||
.pts-unit {
|
||
font-size: $font-sm;
|
||
color: $text-sub;
|
||
}
|
||
|
||
.footer-tip {
|
||
padding: 32rpx 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.footer-txt {
|
||
font-size: $font-sm;
|
||
color: $text-tertiary;
|
||
}
|
||
</style>
|