Files
shoot-miniprograms/src/pages/ranking.vue
2026-05-09 13:44:07 +08:00

869 lines
23 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import Avatar from "@/components/Avatar.vue";
import { topThreeColors } from "@/constants";
import {
getSeasonList,
getSeasonStats,
getScoreRankList,
getTenRingRankList,
getMvpRankList,
} from "@/apis";
import { canEenter } from "@/util";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
const { user, device, online, game } = storeToRefs(store);
const { getLvlName } = store;
const defaultSeasonStats = {
nickName: "",
avatar: "",
season: "",
seasonId: 0,
score: 0,
rankName: "",
rankLvl: 0,
rankIcon: "",
avgRing: 0,
winRate: 0,
modeStats: [],
};
const defaultModeStats = {
"1v1": { modeName: "", count: 0, winRate: 0, mvpCount: 0, scoreRate: 0 },
"2v2": { modeName: "", count: 0, winRate: 0, mvpCount: 0, scoreRate: 0 },
"3v3": { modeName: "", count: 0, winRate: 0, mvpCount: 0, scoreRate: 0 },
"5m": { modeName: "", count: 0, winRate: 0, mvpCount: 0, scoreRate: 0 },
"10m": { modeName: "", count: 0, winRate: 0, mvpCount: 0, scoreRate: 0 },
};
const RANK_PAGE = 1;
const RANK_PAGE_SIZE = 10;
const selectedIndex = ref(0);
const seasonName = ref("");
const seasonData = ref([]);
const currentSeasonId = ref();
const seasonStats = ref({ ...defaultSeasonStats });
const showSeasonList = ref(false);
const currentSeasonData = ref({ ...defaultModeStats });
const rankLoading = ref(false);
const scoreRankList = ref([]);
const mvpRankList = ref([]);
const tenRingRankList = ref([]);
// 根据接口返回结构提取榜单数组,兼容数组和对象两种返回形式。
const getRankListFromResponse = (result) => {
if (Array.isArray(result)) return result;
if (Array.isArray(result?.list)) return result.list;
if (Array.isArray(result?.items)) return result.items;
return [];
};
// 把赛季统计接口里的 modeStats 转成页面当前使用的结构,同时保留后端返回的 modeName。
const buildModeStats = (modeStats = []) => {
const nextStats = {
"1v1": { ...defaultModeStats["1v1"] },
"2v2": { ...defaultModeStats["2v2"] },
"3v3": { ...defaultModeStats["3v3"] },
"5m": { ...defaultModeStats["5m"] },
"10m": { ...defaultModeStats["10m"] },
};
modeStats.forEach((item) => {
if (item.mode === 1) nextStats["1v1"] = { ...nextStats["1v1"], ...item };
if (item.mode === 2) nextStats["2v2"] = { ...nextStats["2v2"], ...item };
if (item.mode === 3) nextStats["3v3"] = { ...nextStats["3v3"], ...item };
if (item.mode === 4) nextStats["5m"] = { ...nextStats["5m"], ...item };
if (item.mode === 5) nextStats["10m"] = { ...nextStats["10m"], ...item };
});
return nextStats;
};
// 当前选中榜单的数据,只用于统一处理加载态和空态。
const currentRankList = computed(() => {
if (selectedIndex.value === 0) return scoreRankList.value;
if (selectedIndex.value === 1) return mvpRankList.value;
return tenRingRankList.value;
});
const latestSeasonId = computed(() => seasonData.value[0]?.seasonId);
const isLatestSeasonSelected = computed(() => {
if (!currentSeasonId.value || !latestSeasonId.value) return true; // 默认显示,防止数据未加载时闪烁
return currentSeasonId.value === latestSeasonId.value;
});
const toMatchPage = async (gameType, teamSize) => {
if (!canEenter(user.value, device.value, online.value)) return;
if (game.value.inBattle) {
uni.$showHint(1);
return;
}
await uni.$checkAudio();
uni.navigateTo({
url: `/pages/match-page?gameType=${gameType}&teamSize=${teamSize}`,
});
};
const toMyGrowthPage = () => {
uni.navigateTo({
url: "/pages/my-growth",
});
};
const toRankListPage = () => {
const tabMap = ["score", "mvp", "tenRing"];
uni.navigateTo({
url: `/pages/rank-list?tab=${tabMap[selectedIndex.value] || "score"}`,
});
};
// 组装榜单副标题,统一处理段位和场次展示。
const formatRankSubTitle = (item = {}) => {
const levelName = item.rankName || getLvlName(item.rankLvl) || "-";
const totalGames = item.totalGames ?? item.TotalGames ?? 0;
return `${levelName}${totalGames}`;
};
// 组装顶部赛季统计文案,区分胜率和得分率。
const formatSeasonText = (stats = {}, rateKey = "winRate", rateLabel = "胜率") => {
const totalGames = stats.count || 0;
const rateValue = stats[rateKey] || 0;
let text = `${totalGames}${rateLabel} ${rateValue}%`;
// 只有当 2V2 (mode=2) 或 3V3 (mode=3) 时,在胜率后面新增 MVP 次数
if (stats.mode === 2 || stats.mode === 3) {
const mvpCount = stats.mvpCount || 0;
text += `MVP次数 ${mvpCount}`;
}
return text;
};
// 统一处理顶部统计展示,避免数值为 0 时被误显示成占位符。
const formatTopStatValue = (value, suffix = "") => {
if (value === undefined || value === null || value === "") return "-";
return `${value}${suffix}`;
};
// 请求赛季统计接口,并更新顶部展示和各模式数据。
const loadSeasonStats = async (seasonId = currentSeasonId.value) => {
const result = await getSeasonStats(seasonId);
seasonStats.value = {
...defaultSeasonStats,
...(result || {}),
};
currentSeasonId.value = seasonStats.value.seasonId || seasonId;
seasonName.value = seasonStats.value.season || seasonName.value;
currentSeasonData.value = buildModeStats(seasonStats.value.modeStats);
};
// 根据当前 tab 请求对应榜单,每次切换都重新获取最新数据,不走缓存。
const loadRankList = async (
index = selectedIndex.value,
seasonId = currentSeasonId.value
) => {
if (!seasonId) return;
rankLoading.value = true;
try {
if (index === 0) {
const result = await getScoreRankList(seasonId, RANK_PAGE, RANK_PAGE_SIZE);
scoreRankList.value = getRankListFromResponse(result);
return;
}
if (index === 1) {
const result = await getMvpRankList(seasonId, RANK_PAGE, RANK_PAGE_SIZE);
mvpRankList.value = getRankListFromResponse(result);
return;
}
const result = await getTenRingRankList(
seasonId,
RANK_PAGE,
RANK_PAGE_SIZE
);
tenRingRankList.value = getRankListFromResponse(result);
} catch (error) {
if (index === 0) scoreRankList.value = [];
else if (index === 1) mvpRankList.value = [];
else tenRingRankList.value = [];
uni.showToast({
title: "排行榜加载失败",
icon: "none",
});
console.error("load rank list error", error);
} finally {
rankLoading.value = false;
}
};
// 切换榜单时更新选中态,并重新请求对应的榜单数据。
const handleSelect = async (index) => {
selectedIndex.value = index;
await loadRankList(index);
};
// 切换赛季时同时刷新顶部统计和当前榜单数据。
const onChangeSeason = async (seasonId, name) => {
showSeasonList.value = false;
if (seasonId === currentSeasonId.value) return;
currentSeasonId.value = seasonId;
seasonName.value = name;
try {
await loadSeasonStats(seasonId);
await loadRankList(selectedIndex.value, seasonId);
} catch (error) {
uni.showToast({
title: "赛季数据加载失败",
icon: "none",
});
console.error("change season error", error);
}
};
// 页面显示时先拿赛季列表,再拉当前赛季统计和默认榜单数据。
onShow(async () => {
try {
const seasonResult = await getSeasonList();
seasonData.value = seasonResult.list || [];
if (!seasonData.value.length) {
seasonStats.value = { ...defaultSeasonStats };
currentSeasonData.value = { ...defaultModeStats };
scoreRankList.value = [];
mvpRankList.value = [];
tenRingRankList.value = [];
showSeasonList.value = false;
return;
}
const currentSeason =
seasonData.value.find((item) => item.seasonId === currentSeasonId.value) ||
seasonData.value[0];
currentSeasonId.value = currentSeason.seasonId;
seasonName.value = currentSeason.seasonName;
showSeasonList.value = false;
await Promise.all([
loadSeasonStats(currentSeasonId.value),
loadRankList(selectedIndex.value, currentSeasonId.value),
]);
} catch (error) {
uni.showToast({
title: "页面数据加载失败",
icon: "none",
});
console.error("ranking page load error", error);
}
});
</script>
<template>
<Container title="排位赛" :showBackToGame="true" :bgType="6">
<view class="battle-types-box">
<view class="battle-types">
<view class="first">
<image src="../static/rank/battle-choose.png" mode="widthFix" />
<image class="star" src="../static/rank/star.png" mode="widthFix" />
</view>
<image
src="../static/rank/battle1v1.svg"
mode="widthFix"
@click.stop="() => toMatchPage(1, 2)"
/>
<image
src="../static/rank/battle2v2.svg"
mode="widthFix"
@click.stop="() => toMatchPage(2, 4)"
/>
<image
src="../static/rank/battle3v3.svg"
mode="widthFix"
@click.stop="() => toMatchPage(3, 6)"
/>
<image
src="../static/rank/battle5.svg"
mode="widthFix"
@click.stop="() => toMatchPage(4, 5)"
/>
<image
src="../static/rank/battle10.svg"
mode="widthFix"
@click.stop="() => toMatchPage(5, 10)"
/>
</view>
</view>
<view class="container" @click="() => (showSeasonList = false)">
<view class="ranking-my-data" v-if="user.id">
<view>
<view class="user-info">
<Avatar
:src="user.avatar"
:rankLvl="seasonStats.rankLvl"
:size="30"
/>
<text>{{ seasonStats.nickName || user.nickName }}</text>
</view>
<view
class="ranking-season"
v-show="seasonData.length"
@click.stop="() => (showSeasonList = true)"
>
<text>{{ seasonName }}</text>
<image
v-show="seasonData.length > 1"
src="../static/rank/triangle.png"
mode="widthFix"
/>
<view class="season-list" v-if="showSeasonList">
<view
v-for="(item, index) in seasonData"
:key="index"
@click.stop="
() => onChangeSeason(item.seasonId, item.seasonName)
"
>
<text
:style="{
color: item.seasonName === seasonName ? '#E7BA80' : '#FFFFFF',
}"
>
{{ item.seasonName }}
</text>
<image
v-if="item.seasonName === seasonName"
src="../static/rank/triangle.png"
mode="widthFix"
/>
</view>
</view>
</view>
</view>
<view class="my-data">
<view>
<text>段位</text>
<text :style="{ color: '#E7BA80' }">
{{ seasonStats.rankName || getLvlName(seasonStats.rankLvl) || "-" }}
</text>
</view>
<view>
<text>赛季平均环数</text>
<text :style="{ color: '#E7BA80' }">
{{ formatTopStatValue(seasonStats.avgRing, "环") }}
</text>
</view>
<view>
<text>赛季胜率</text>
<text :style="{ color: '#E7BA80' }">
{{ formatTopStatValue(seasonStats.winRate, "%") }}
</text>
</view>
<view class="my-rank-score">
<image src="../static/rank/bubble-tip.png" mode="widthFix" />
<text>积分{{ seasonStats.score }}</text>
</view>
</view>
<view class="data-progress">
<view class="data-name-box">
<text class="data-name">{{ currentSeasonData["1v1"].modeName || "1V1" }}&nbsp;</text>
<text>{{ formatSeasonText(currentSeasonData["1v1"], "winRate", "胜率") }}</text>
</view>
<view class="data-progress-line">
<view
:style="{ width: `${currentSeasonData['1v1'].winRate}%` }"
/>
</view>
</view>
<view class="data-progress">
<view class="data-name-box">
<text class="data-name">{{ currentSeasonData["2v2"].modeName || "2V2" }}&nbsp;</text>
<text>{{ formatSeasonText(currentSeasonData["2v2"], "winRate", "胜率") }}</text>
</view>
<view class="data-progress-line">
<view
:style="{ width: `${currentSeasonData['2v2'].winRate}%` }"
/>
</view>
</view>
<view class="data-progress">
<view class="data-name-box">
<text class="data-name">{{ currentSeasonData["3v3"].modeName || "3V3" }}&nbsp;</text>
<text>{{ formatSeasonText(currentSeasonData["3v3"], "winRate", "胜率") }}</text>
</view>
<view class="data-progress-line">
<view
:style="{ width: `${currentSeasonData['3v3'].winRate}%` }"
/>
</view>
</view>
<view class="data-progress">
<view class="data-name-box">
<text class="data-name">{{ currentSeasonData["5m"].modeName || "5V5" }}&nbsp;</text>
<text>{{ formatSeasonText(currentSeasonData["5m"], "scoreRate", "得分率") }}</text>
</view>
<view class="data-progress-line">
<view
:style="{ width: `${currentSeasonData['5m'].scoreRate}%` }"
/>
</view>
</view>
<view class="data-progress">
<view class="data-name-box">
<text class="data-name">{{ currentSeasonData["10m"].modeName || "10人大乱斗" }}&nbsp;</text>
<text>{{ formatSeasonText(currentSeasonData["10m"], "scoreRate", "得分率") }}</text>
</view>
<view class="data-progress-line">
<view
:style="{ width: `${currentSeasonData['10m'].scoreRate}%` }"
/>
</view>
</view>
<view class="growth-btn" @click.stop="toMyGrowthPage">
查看我的比赛记录
<image
style="width: 30rpx; vertical-align: -2px"
src="../static/enter.png"
mode="widthFix"
/>
</view>
</view>
<view class="ranking-data">
<view>
<view
v-for="(rankType, index) in ['积分榜', 'MVP榜', '十环榜']"
:key="index"
:style="{
color: '#fff',
opacity: index === selectedIndex ? '1' : '0.7',
background:
index === selectedIndex
? 'linear-gradient(133deg, #FFD19A 0%, #A17636 100%)'
: 'rgba(255,255,255,0.1)',
}"
@tap="handleSelect(index)"
>
{{ rankType }}
</view>
</view>
<view
v-if="selectedIndex === 0"
v-for="(item, index) in scoreRankList"
:key="`score-${index}`"
:style="{ backgroundColor: index % 2 === 0 ? '#9898981f' : 'transparent' }"
class="rank-item"
>
<image v-if="index === 0" src="../static/champ1.png" mode="widthFix" />
<image v-if="index === 1" src="../static/champ2.png" mode="widthFix" />
<image v-if="index === 2" src="../static/champ3.png" mode="widthFix" />
<view v-if="index > 2">{{ index + 1 }}</view>
<image
:src="item.avatar || '../static/user-icon.png'"
mode="widthFix"
:style="{ borderColor: index < 3 ? topThreeColors[index] : '' }"
/>
<view>
<text class="truncate">{{ item.name }}</text>
<text>{{ formatRankSubTitle(item) }}</text>
</view>
<text>{{ item.totalScore || 0 }}<text></text></text>
</view>
<view
v-if="selectedIndex === 1"
v-for="(item, index) in mvpRankList"
:key="`mvp-${index}`"
:style="{ backgroundColor: index % 2 === 0 ? '#9898981f' : 'transparent' }"
class="rank-item"
>
<image v-if="index === 0" src="../static/champ1.png" mode="widthFix" />
<image v-if="index === 1" src="../static/champ2.png" mode="widthFix" />
<image v-if="index === 2" src="../static/champ3.png" mode="widthFix" />
<view v-if="index > 2">{{ index + 1 }}</view>
<image
:src="item.avatar || '../static/user-icon.png'"
mode="widthFix"
:style="{ borderColor: index < 3 ? topThreeColors[index] : '' }"
/>
<view>
<text class="truncate">{{ item.name }}</text>
<text>{{ formatRankSubTitle(item) }}</text>
</view>
<text>{{ item.mvpCount || 0 }}<text></text></text>
</view>
<view
v-if="selectedIndex === 2"
v-for="(item, index) in tenRingRankList"
:key="`ten-ring-${index}`"
:style="{ backgroundColor: index % 2 === 0 ? '#9898981f' : 'transparent' }"
class="rank-item"
>
<image v-if="index === 0" src="../static/champ1.png" mode="widthFix" />
<image v-if="index === 1" src="../static/champ2.png" mode="widthFix" />
<image v-if="index === 2" src="../static/champ3.png" mode="widthFix" />
<view v-if="index > 2">{{ index + 1 }}</view>
<image
:src="item.avatar || '../static/user-icon.png'"
mode="widthFix"
:style="{ borderColor: index < 3 ? topThreeColors[index] : '' }"
/>
<view>
<text class="truncate">{{ item.name }}</text>
<text>{{ formatRankSubTitle(item) }}</text>
</view>
<text>{{ item.tenRings ?? item.TenRings ?? 0 }}<text></text></text>
</view>
<view v-if="rankLoading" class="no-data">
<text>加载中...</text>
</view>
<view v-else-if="!currentRankList.length" class="no-data">
<text>暂无数据</text>
</view>
<view v-if="isLatestSeasonSelected" class="see-more" @click.stop="toRankListPage">点击查看更多</view>
</view>
</view>
</Container>
</template>
<style scoped>
.container {
width: 100%;
padding-bottom: 40rpx;
}
.ranking-my-data,
.ranking-data {
display: flex;
flex-direction: column;
align-items: flex-start;
background: rgba(84, 67, 29, 0.3);
border: 2rpx solid rgba(255, 217, 71, 0.2);
border-radius: 24rpx;
margin: 0 15px;
margin-bottom: 20px;
}
.ranking-my-data {
padding: 30rpx 30rpx 38rpx 30rpx;
margin-bottom: 16rpx;
}
.ranking-my-data > view:first-of-type {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-bottom: 15px;
}
.user-info {
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-size: 14px;
}
.user-info > text {
margin-left: 15px;
}
.ranking-season {
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.ranking-season > image {
width: 12px;
height: 12px;
}
.ranking-season > text {
color: #e7ba80;
font-size: 14px;
margin-right: 5px;
}
.my-data {
display: flex;
align-items: center;
justify-content: space-around;
color: #b3b3b3;
width: 110%;
margin-top: 15px;
position: relative;
transform: translateX(-5%);
}
.my-data > view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
width: 32%;
height: 60px;
position: relative;
}
.my-data > view:nth-child(2) {
border-left: 1px solid #48494e;
border-right: 1px solid #48494e;
padding: 0 15px;
}
.my-data > view > text:first-child {
font-size: 14px;
}
.my-data > view > text:last-child {
font-size: 18px;
}
.battle-types-box {
width: 100%;
padding: 28rpx;
box-sizing: border-box;
}
.battle-types {
display: grid;
grid-template-columns: repeat(3, 1fr);
row-gap: 8px;
column-gap: 8px;
}
.battle-types > image {
width: 220rpx;
height: 108rpx;
}
.battle-types .first {
width: 220rpx;
height: 108rpx;
position: relative;
}
.battle-types .first > image {
width: 220rpx;
height: 108rpx;
}
.battle-types .first .star {
width: 94rpx;
height: 98rpx;
position: absolute;
left: -22rpx;
bottom: 0;
}
.data-progress {
width: 100%;
color: #b3b3b3;
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: 15px;
font-size: 14px;
}
.data-progress .data-name {
color: #ffffff;
}
.data-progress .data-progress-line {
width: 100%;
height: 5px;
border-radius: 10px;
background-color: #696969;
margin-top: 10px;
}
.data-progress .data-progress-line > view {
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
height: 5px;
border-radius: 10px;
transition: width 0.3s ease;
}
.growth-btn {
color: #999999;
font-size: 14px;
text-align: center;
width: 100%;
padding-top: 15px;
}
.ranking-data > view:first-of-type {
width: calc(100% - 30px);
display: flex;
justify-content: space-around;
font-size: 15px;
padding: 15px;
}
.ranking-data > view:first-of-type > view {
width: 25%;
text-align: center;
border-radius: 20px;
font-size: 30rpx;
word-break: keep-all;
line-height: 70rpx;
}
.rank-item {
width: calc(100% - 30px);
height: 55px;
padding: 0 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.rank-item > view:first-child {
width: 24px;
height: 24px;
border-radius: 12px;
background-color: #767676;
color: #fff;
text-align: center;
line-height: 24px;
font-size: 14px;
}
.rank-item > image:first-child {
width: 24px;
height: 24px;
}
.rank-item > image:nth-child(2) {
width: 35px;
min-height: 35px;
max-height: 35px;
border-radius: 50%;
border: 1px solid transparent;
}
.rank-item > view:nth-child(3) {
display: flex;
flex-direction: column;
width: 50%;
}
.rank-item > view:nth-child(3) > text:first-child {
color: #fff9;
font-size: 14px;
width: 120px;
}
.rank-item > view:nth-child(3) > text:last-child {
color: #fff4;
font-size: 13px;
}
.rank-item > text:last-child {
color: #fff;
width: 56px;
text-align: right;
}
.rank-item > text:last-child text {
color: #fff4;
font-size: 13px;
margin-left: 3px;
}
.see-more {
color: #39a8ff;
font-size: 14px;
text-align: center;
width: 100%;
margin-top: 5px;
margin-bottom: 10px;
}
.no-data {
width: 100%;
height: 200px;
display: flex;
justify-content: center;
align-items: center;
color: #fff9;
}
.season-list {
background-color: #000c;
border-radius: 15px;
color: #fff;
font-size: 12px;
padding: 5px 0;
position: absolute;
width: 220rpx;
height: auto;
max-height: 400rpx;
overflow: hidden;
overflow-y: auto;
top: -44rpx;
right: -30rpx;
letter-spacing: 2px;
z-index: 10;
text-align: center;
}
.season-list > view {
display: flex;
align-items: center;
word-break: keep-all;
padding: 20rpx 0;
}
.season-list > view > text {
width: 140rpx;
text-align: right;
}
.season-list > view > image {
width: 24rpx;
height: 24rpx;
min-width: 24rpx;
min-height: 24rpx;
margin-left: 20rpx;
}
.my-rank-score {
position: absolute !important;
color: #333333;
width: 80px !important;
display: flex;
justify-content: center;
top: -34px;
left: 20px;
}
.my-rank-score > image {
position: absolute;
width: 100%;
}
.my-rank-score > text {
position: relative;
font-size: 10px !important;
margin-bottom: 7px;
}
</style>