Merge branch 'feat-zhangyi' into test
269
AGENTS.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# AI Agent 企业级行为策略(Ultimate Edition)
|
||||
|
||||
## 核心目标
|
||||
|
||||
AI 应:
|
||||
|
||||
* 像高级工程师一样思考
|
||||
* 保持智能
|
||||
* 保持上下文理解能力
|
||||
* 保持组件联动能力
|
||||
* 同时避免无意义 token 消耗
|
||||
|
||||
目标不是限制 AI。
|
||||
|
||||
目标是:
|
||||
|
||||
* 智能
|
||||
* 克制
|
||||
* 稳定
|
||||
* 高效
|
||||
|
||||
---
|
||||
|
||||
# AI 工作模式
|
||||
|
||||
默认采用:
|
||||
|
||||
Think First
|
||||
Explore Second
|
||||
Modify Last
|
||||
|
||||
即:
|
||||
|
||||
1. 先理解需求
|
||||
2. 再推理可能相关文件
|
||||
3. 再最小化读取
|
||||
4. 最后修改代码
|
||||
|
||||
禁止:
|
||||
|
||||
* 无脑全项目扫描
|
||||
* 不经思考直接 grep
|
||||
* 无限递归读取
|
||||
|
||||
---
|
||||
|
||||
# 智能按需扫描(核心规则)
|
||||
|
||||
允许 AI 自动:
|
||||
|
||||
* 分析当前任务
|
||||
* 分析 import
|
||||
* 分析组件依赖
|
||||
* 分析 store 依赖
|
||||
* 分析 api 依赖
|
||||
* 分析 types 依赖
|
||||
* 分析 utils 依赖
|
||||
|
||||
允许:
|
||||
|
||||
* 自动读取直接依赖文件
|
||||
* 自动修复 import
|
||||
* 自动修复类型引用
|
||||
* 自动分析运行链路
|
||||
|
||||
但必须:
|
||||
|
||||
* 最小化扫描范围
|
||||
* 最小化 token 消耗
|
||||
* 禁止无限递归探索
|
||||
|
||||
---
|
||||
|
||||
# 扫描深度限制
|
||||
|
||||
默认最大依赖深度:
|
||||
|
||||
2 层
|
||||
|
||||
例如:
|
||||
|
||||
index.vue
|
||||
-> ProductCard.vue
|
||||
-> product.ts
|
||||
|
||||
允许读取:
|
||||
|
||||
* ProductCard.vue
|
||||
* product.ts
|
||||
|
||||
禁止继续无限扫描。
|
||||
|
||||
如果任务复杂:
|
||||
|
||||
必须先输出分析计划,
|
||||
等待确认后再扩大扫描范围。
|
||||
|
||||
---
|
||||
|
||||
# AI 自由发挥边界
|
||||
|
||||
允许:
|
||||
|
||||
* 合理重构
|
||||
* 合理组件化
|
||||
* 合理优化结构
|
||||
* 合理优化样式
|
||||
* 合理优化复用
|
||||
* 合理修复低级问题
|
||||
* 合理修复 import
|
||||
* 合理修复类型错误
|
||||
|
||||
禁止:
|
||||
|
||||
* 为了炫技重构项目
|
||||
* 无意义抽象
|
||||
* 过度设计
|
||||
* 无意义拆分
|
||||
* 无意义新增依赖
|
||||
* 自动升级依赖
|
||||
|
||||
---
|
||||
|
||||
# Token 经济策略
|
||||
|
||||
Token 应优先用于:
|
||||
|
||||
* 推理
|
||||
* 架构理解
|
||||
* 业务逻辑
|
||||
* UI 结构优化
|
||||
* 类型安全
|
||||
* 组件联动
|
||||
|
||||
禁止浪费在:
|
||||
|
||||
* 全项目 grep
|
||||
* 重复读取
|
||||
* 重复输出
|
||||
* 重复解释
|
||||
* 输出完整项目
|
||||
* 输出未修改代码
|
||||
|
||||
---
|
||||
|
||||
# 页面生成规则(Figma / uni-app)
|
||||
|
||||
允许:
|
||||
|
||||
* 自动组件化
|
||||
* 自动布局优化
|
||||
* 自动结构优化
|
||||
* 自动提取公共组件
|
||||
|
||||
优先:
|
||||
|
||||
* flex 布局
|
||||
* 可维护性
|
||||
* uni-app 最佳实践
|
||||
* 低嵌套结构
|
||||
* 高复用结构
|
||||
|
||||
禁止:
|
||||
|
||||
* div 套 div
|
||||
* 全 absolute 页面
|
||||
* 垃圾 HTML
|
||||
* 无意义嵌套
|
||||
* 内联 style 泛滥
|
||||
|
||||
---
|
||||
|
||||
# uni-app 规则
|
||||
|
||||
必须:
|
||||
|
||||
* 使用 view/text/image
|
||||
* px 转 rpx
|
||||
* 使用 script setup
|
||||
* scoped scss
|
||||
* 兼容:
|
||||
|
||||
* H5
|
||||
* 微信小程序
|
||||
* App
|
||||
|
||||
---
|
||||
|
||||
# 大任务策略
|
||||
|
||||
复杂任务:
|
||||
|
||||
必须:
|
||||
|
||||
1. 先分析
|
||||
2. 先规划
|
||||
3. 先输出方案
|
||||
4. 等待确认
|
||||
|
||||
再:
|
||||
|
||||
5. 编码
|
||||
|
||||
禁止直接进入大规模代码生成。
|
||||
|
||||
---
|
||||
|
||||
# 修改策略
|
||||
|
||||
优先:
|
||||
|
||||
* diff 修改
|
||||
* 小范围 patch
|
||||
* 保持现有架构
|
||||
* 保持现有组件体系
|
||||
* 保持现有 API 结构
|
||||
|
||||
允许:
|
||||
|
||||
* 小范围智能优化
|
||||
|
||||
禁止:
|
||||
|
||||
* 全项目重构
|
||||
* 无关文件修改
|
||||
|
||||
---
|
||||
|
||||
# 高级工程师行为模式
|
||||
|
||||
AI 应像高级工程师:
|
||||
|
||||
* 先思考
|
||||
* 再探索
|
||||
* 再修改
|
||||
|
||||
而不是:
|
||||
|
||||
* 无脑扫描器
|
||||
* Token 消耗机器
|
||||
* 低级代码生成器
|
||||
|
||||
AI 应主动:
|
||||
|
||||
* 控制扫描范围
|
||||
* 控制输出长度
|
||||
* 控制修改范围
|
||||
* 控制复杂度
|
||||
|
||||
同时保持:
|
||||
|
||||
* 智能
|
||||
* 联动能力
|
||||
* 架构理解能力
|
||||
|
||||
---
|
||||
|
||||
# 默认输出规则
|
||||
|
||||
默认:
|
||||
|
||||
* 仅输出修改部分
|
||||
* 不重复未修改代码
|
||||
* 少解释
|
||||
* 优先 patch
|
||||
* 优先 diff
|
||||
|
||||
除非用户明确要求:
|
||||
否则不要输出完整项目。
|
||||
60
src/apis.js
@@ -496,3 +496,63 @@ export const kickPlayerAPI = (number, userId) => {
|
||||
userId,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取赛季列表
|
||||
export const getSeasonList = () => {
|
||||
return request("GET", "/index/season/list");
|
||||
};
|
||||
|
||||
// 获取赛季统计
|
||||
export const getSeasonStats = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/season/stats", data);
|
||||
};
|
||||
|
||||
//获取积分榜
|
||||
export const getScoreRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/score/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取10环排行榜
|
||||
export const getTenRingRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/tenRing/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取MVP排行榜
|
||||
export const getMvpRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/mvp/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取我的积分排名
|
||||
export const getMyScoreRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myScoreRank", data);
|
||||
};
|
||||
|
||||
// 获取我的MVP排名
|
||||
export const getMyMvpRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myMvpRank", data);
|
||||
};
|
||||
|
||||
// 获取我的10环排名
|
||||
export const getMyTenRingRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myTenRingRank", data);
|
||||
};
|
||||
|
||||
@@ -51,6 +51,12 @@ const props = defineProps({
|
||||
src="https://static.shelingxingqiu.com/attachment/2026-01-05/dfgf3b5kp459tfyn0f.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 6"
|
||||
src="../static/rank/rank-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="bg-overlay" v-if="type === 0"></view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -67,7 +73,7 @@ const props = defineProps({
|
||||
|
||||
.bg-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* height: 100%; */
|
||||
}
|
||||
|
||||
.bg-overlay {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getDeviceBatteryAPI,
|
||||
getHomeData,
|
||||
getMyDevicesAPI,
|
||||
getRankListAPI,
|
||||
getScoreRankList,
|
||||
silentLoginAPI,
|
||||
} from "@/apis";
|
||||
import {topThreeColors} from "@/constants";
|
||||
@@ -26,15 +26,23 @@ const {
|
||||
updateConfig,
|
||||
updateUser,
|
||||
updateDevice,
|
||||
updateRank,
|
||||
getLvlName,
|
||||
getLvlNameByScore,
|
||||
updateOnline,
|
||||
} = store;
|
||||
const {user, device, rankData, online, game} = storeToRefs(store);
|
||||
const {user, device, online, game} = storeToRefs(store);
|
||||
|
||||
const showModal = ref(false);
|
||||
const showGuide = ref(false);
|
||||
const scoreRankList = ref([]);
|
||||
|
||||
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
|
||||
const getScoreRankData = (result) => {
|
||||
if (Array.isArray(result)) return result;
|
||||
if (Array.isArray(result?.list)) return result.list;
|
||||
if (Array.isArray(result?.items)) return result.items;
|
||||
return [];
|
||||
};
|
||||
|
||||
const toPage = async (path) => {
|
||||
if (!user.value.id) {
|
||||
@@ -84,15 +92,15 @@ onShow(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const promises = [getRankListAPI()];
|
||||
const promises = [getScoreRankList(undefined, 1, 10)];
|
||||
if (token || user.value.id) {
|
||||
promises.push(getHomeData());
|
||||
}
|
||||
|
||||
const [rankList, homeData] = await Promise.all(promises);
|
||||
|
||||
console.log("排行数据", rankList);
|
||||
updateRank(rankList);
|
||||
console.log("积分榜数据", rankList);
|
||||
scoreRankList.value = getScoreRankData(rankList).slice(0, 10);
|
||||
|
||||
if (homeData) {
|
||||
console.log("首页数据:", homeData);
|
||||
@@ -216,7 +224,7 @@ onShareTimeline(() => {
|
||||
class="player-avatar"
|
||||
:style="{
|
||||
zIndex: 8 - i,
|
||||
borderColor: rankData.rank[i - 1]
|
||||
borderColor: scoreRankList[i - 1]
|
||||
? topThreeColors[i - 1] || '#000'
|
||||
: '#000',
|
||||
}"
|
||||
@@ -227,15 +235,15 @@ onShareTimeline(() => {
|
||||
<view v-if="i > 3">{{ i }}</view>
|
||||
<image
|
||||
:src="
|
||||
rankData.rank[i - 1]
|
||||
? rankData.rank[i - 1].avatar
|
||||
scoreRankList[i - 1]
|
||||
? (scoreRankList[i - 1].avatar || '../static/user-icon.png')
|
||||
: '../static/user-icon-dark.png'
|
||||
"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
<view class="more-players">
|
||||
<text>{{ rankData.rank.length }}</text>
|
||||
<text>{{ scoreRankList.length }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,58 +1,333 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import {
|
||||
getMvpRankList,
|
||||
getMyMvpRank,
|
||||
getMyScoreRank,
|
||||
getMyTenRingRank,
|
||||
getScoreRankList,
|
||||
getTenRingRankList,
|
||||
} from "@/apis";
|
||||
import { capsuleHeight } from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const store = useStore();
|
||||
const { user, rankData } = storeToRefs(store);
|
||||
const { user } = storeToRefs(store);
|
||||
const { getLvlName } = store;
|
||||
|
||||
const selectedIndex = ref(0);
|
||||
const currentList = ref([]);
|
||||
const myData = ref({});
|
||||
const addBg = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
handleSelect(0);
|
||||
const createRankState = () => ({
|
||||
list: [],
|
||||
page: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
loading: false,
|
||||
noMore: false,
|
||||
loaded: false,
|
||||
scrollTop: 0,
|
||||
myData: null,
|
||||
myDataLoaded: false,
|
||||
});
|
||||
|
||||
const handleSelect = (index) => {
|
||||
selectedIndex.value = index;
|
||||
myData.value = {};
|
||||
currentList.value = [];
|
||||
if (index === 0) {
|
||||
currentList.value = rankData.value.rank;
|
||||
} else if (index === 1) {
|
||||
currentList.value = rankData.value.mvpRank;
|
||||
} else if (index === 2) {
|
||||
currentList.value = rankData.value.ringRank;
|
||||
const rankTabs = [
|
||||
{
|
||||
key: "score",
|
||||
title: "积分榜",
|
||||
subTitle: "排位赛积分",
|
||||
listApi: getScoreRankList,
|
||||
myApi: getMyScoreRank,
|
||||
},
|
||||
{
|
||||
key: "mvp",
|
||||
title: "MVP榜",
|
||||
subTitle: "MVP次数",
|
||||
listApi: getMvpRankList,
|
||||
myApi: getMyMvpRank,
|
||||
},
|
||||
{
|
||||
key: "tenRing",
|
||||
title: "十环榜",
|
||||
subTitle: "十环次数",
|
||||
listApi: getTenRingRankList,
|
||||
myApi: getMyTenRingRank,
|
||||
},
|
||||
];
|
||||
|
||||
// 解析 ranking 页面传入的榜单参数,进入页面时默认选中对应 tab。
|
||||
const getTabIndexByRouteParam = (tab) => {
|
||||
if (tab === "mvp") return 1;
|
||||
if (tab === "tenRing") return 2;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const rankStates = ref({
|
||||
score: createRankState(),
|
||||
mvp: createRankState(),
|
||||
tenRing: createRankState(),
|
||||
});
|
||||
|
||||
const selectedIndex = ref(0);
|
||||
const initialTabIndex = ref(0);
|
||||
const pageMounted = ref(false);
|
||||
const initializedFromRoute = ref(false);
|
||||
const addBg = ref(false);
|
||||
const currentScrollTop = ref(0);
|
||||
const restoreScrollTop = ref(0);
|
||||
const tabSwitchAnimating = ref(false);
|
||||
const suppressScrollSync = ref(false);
|
||||
const suppressLoadMore = ref(false);
|
||||
const stickyTabsTop = capsuleHeight + 50;
|
||||
const stickyTabsActive = ref(false);
|
||||
const tabsStickyThreshold = ref(0);
|
||||
const tabsStickyReady = ref(false);
|
||||
const tabsHeight = ref(0);
|
||||
|
||||
const getTabConfig = (index = selectedIndex.value) => rankTabs[index];
|
||||
const getTabKey = (index = selectedIndex.value) => getTabConfig(index).key;
|
||||
|
||||
// 统一提取榜单接口返回的列表数据,兼容数组和对象两种返回格式。
|
||||
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 [];
|
||||
};
|
||||
|
||||
// 为当前登录用户构造默认的个人榜单信息,避免接口未返回时底部区域缺数据。
|
||||
const buildDefaultMyData = () => ({
|
||||
rank: null,
|
||||
userId: user.value.id,
|
||||
name: user.value.nickName,
|
||||
avatar: user.value.avatar,
|
||||
totalScore: 0,
|
||||
mvpCount: 0,
|
||||
tenRings: 0,
|
||||
totalGames: 0,
|
||||
totalCount: 0,
|
||||
rankName: user.value.lvlName,
|
||||
rankLvl: user.value.rankLvl,
|
||||
});
|
||||
|
||||
const currentTabKey = computed(() => getTabKey(selectedIndex.value));
|
||||
const currentState = computed(() => rankStates.value[currentTabKey.value]);
|
||||
const currentList = computed(() => currentState.value.list);
|
||||
const currentSubTitle = computed(() => getTabConfig(selectedIndex.value).subTitle);
|
||||
const currentMyData = computed(() => {
|
||||
if (!user.value.id) return null;
|
||||
return currentState.value.myData || buildDefaultMyData();
|
||||
});
|
||||
|
||||
// 统一格式化段位和场次文案,兼容不同接口的字段命名。
|
||||
const formatLevelText = (item = {}) => {
|
||||
const levelName = item.rankName || getLvlName(item.rankLvl) || "暂无段位";
|
||||
const totalGames = item.totalGames ?? item.TotalGames ?? 0;
|
||||
return `${levelName},${totalGames}场`;
|
||||
};
|
||||
|
||||
// 统一读取榜单项的排名字段,没有后端 rank 时回退到前端序号。
|
||||
const getDisplayRank = (item = {}, index = 0) => {
|
||||
return item.rank ?? index + 1;
|
||||
};
|
||||
|
||||
// 底部个人排名在未上榜时展示占位符,而不是空白。
|
||||
const getDisplayMyRank = (item = {}) => {
|
||||
return item.rank ?? "-";
|
||||
};
|
||||
|
||||
const getScoreValue = (item = {}) => item.totalScore ?? 0;
|
||||
const getMvpValue = (item = {}) => item.mvpCount ?? item.totalScore ?? 0;
|
||||
const getTenRingValue = (item = {}) =>
|
||||
item.tenRings ?? item.TenRings ?? item.totalScore ?? 0;
|
||||
|
||||
// 根据当前选中的榜单类型,读取对应的展示值。
|
||||
const getRankValue = (item = {}, index = selectedIndex.value) => {
|
||||
if (index === 0) return getScoreValue(item);
|
||||
if (index === 1) return getMvpValue(item);
|
||||
return getTenRingValue(item);
|
||||
};
|
||||
|
||||
const getRankUnit = (index = selectedIndex.value) => {
|
||||
if (index === 0) return "分";
|
||||
return "次";
|
||||
};
|
||||
|
||||
// 统一设置页面当前的视觉滚动状态,避免吸顶和顶部背景不同步。
|
||||
const syncScrollVisualState = (scrollTop = 0) => {
|
||||
currentScrollTop.value = scrollTop;
|
||||
addBg.value = scrollTop > 100;
|
||||
if (!tabsStickyReady.value) {
|
||||
stickyTabsActive.value = false;
|
||||
return;
|
||||
}
|
||||
if (user.value.id) {
|
||||
currentList.value.some((item) => {
|
||||
if (item.userId === user.value.id) {
|
||||
myData.value = item;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!myData.value.userId) {
|
||||
myData.value = {
|
||||
userId: user.value.id,
|
||||
TotalGames: 0,
|
||||
totalScore: 0,
|
||||
mvpCount: 0,
|
||||
TenRings: 0,
|
||||
};
|
||||
stickyTabsActive.value = scrollTop >= tabsStickyThreshold.value;
|
||||
};
|
||||
|
||||
// 只保留一条滚动恢复链路:从当前滚动位置平滑滚到目标位置,避免多套控制同时生效造成闪烁。
|
||||
const applyScrollPosition = async (
|
||||
fromScrollTop = currentScrollTop.value,
|
||||
toScrollTop = 0,
|
||||
withAnimation = false
|
||||
) => {
|
||||
tabSwitchAnimating.value = withAnimation;
|
||||
restoreScrollTop.value = fromScrollTop;
|
||||
await nextTick();
|
||||
restoreScrollTop.value = toScrollTop;
|
||||
syncScrollVisualState(toScrollTop);
|
||||
};
|
||||
|
||||
// 请求指定榜单的某一页数据,只有当前榜单会追加分页,不影响其他榜单的浏览状态。
|
||||
const loadRankPage = async (tabKey, { reset = false } = {}) => {
|
||||
const state = rankStates.value[tabKey];
|
||||
const config = rankTabs.find((item) => item.key === tabKey);
|
||||
if (!config || state.loading) return;
|
||||
if (!reset && state.noMore) return;
|
||||
|
||||
const nextPage = reset ? 1 : state.page + 1;
|
||||
state.loading = true;
|
||||
if (reset) state.noMore = false;
|
||||
|
||||
try {
|
||||
const result = await config.listApi(undefined, nextPage, PAGE_SIZE);
|
||||
const list = getRankListFromResponse(result);
|
||||
state.list = reset ? list : state.list.concat(list);
|
||||
state.page = nextPage;
|
||||
state.loaded = true;
|
||||
state.noMore = list.length < PAGE_SIZE;
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
state.list = [];
|
||||
state.page = 0;
|
||||
state.loaded = false;
|
||||
state.noMore = false;
|
||||
}
|
||||
uni.showToast({
|
||||
title: "排行榜加载失败",
|
||||
icon: "none",
|
||||
});
|
||||
console.error("load rank page error", error);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onScrollView = (e) => {
|
||||
addBg.value = e.detail.scrollTop > 100;
|
||||
// 每个榜单独立请求一次个人排名信息,切回该榜单时直接复用,避免打断浏览上下文。
|
||||
const loadMyRankData = async (tabKey) => {
|
||||
if (!user.value.id) return;
|
||||
|
||||
const state = rankStates.value[tabKey];
|
||||
const config = rankTabs.find((item) => item.key === tabKey);
|
||||
if (!config || state.myDataLoaded) return;
|
||||
|
||||
try {
|
||||
const result = await config.myApi();
|
||||
state.myData = {
|
||||
...buildDefaultMyData(),
|
||||
...(result || {}),
|
||||
};
|
||||
} catch (error) {
|
||||
state.myData = buildDefaultMyData();
|
||||
console.error("load my rank data error", error);
|
||||
} finally {
|
||||
state.myDataLoaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
// 首次进入或切换到未加载过的榜单时,初始化它的分页数据和个人横条数据。
|
||||
const ensureTabReady = async (index = selectedIndex.value) => {
|
||||
const tabKey = getTabKey(index);
|
||||
const state = rankStates.value[tabKey];
|
||||
|
||||
if (!state.loaded) {
|
||||
await loadRankPage(tabKey, { reset: true });
|
||||
}
|
||||
|
||||
if (user.value.id && !state.myDataLoaded) {
|
||||
await loadMyRankData(tabKey);
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
return state.scrollTop || 0;
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
initialTabIndex.value = getTabIndexByRouteParam(options.tab);
|
||||
selectedIndex.value = initialTabIndex.value;
|
||||
if (pageMounted.value && !initializedFromRoute.value) {
|
||||
initializePage();
|
||||
}
|
||||
});
|
||||
|
||||
// 页面初始化同时兼容 onLoad 和 onMounted 的先后顺序,确保首屏一定落到路由指定的榜单。
|
||||
const initializePage = async () => {
|
||||
if (initializedFromRoute.value) return;
|
||||
initializedFromRoute.value = true;
|
||||
const nextScrollTop = await ensureTabReady(selectedIndex.value);
|
||||
await applyScrollPosition(0, nextScrollTop, false);
|
||||
setTimeout(() => {
|
||||
measureTabsMetrics();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
pageMounted.value = true;
|
||||
await initializePage();
|
||||
});
|
||||
|
||||
// 切换榜单时保留原榜单的列表和滚动位置,切回来后继续从之前的位置浏览。
|
||||
const handleSelect = async (index) => {
|
||||
if (index === selectedIndex.value) return;
|
||||
|
||||
const previousTabKey = currentTabKey.value;
|
||||
rankStates.value[previousTabKey].scrollTop = currentScrollTop.value;
|
||||
const previousScrollTop = currentScrollTop.value;
|
||||
|
||||
suppressScrollSync.value = true;
|
||||
suppressLoadMore.value = true;
|
||||
selectedIndex.value = index;
|
||||
const nextScrollTop = await ensureTabReady(index);
|
||||
await applyScrollPosition(previousScrollTop, nextScrollTop, false);
|
||||
setTimeout(() => {
|
||||
tabSwitchAnimating.value = false;
|
||||
suppressScrollSync.value = false;
|
||||
suppressLoadMore.value = false;
|
||||
}, 220);
|
||||
};
|
||||
|
||||
// 触底后只加载当前榜单的下一页数据,其他榜单的数据和页码保持不变。
|
||||
const loadMore = async () => {
|
||||
if (suppressLoadMore.value) return;
|
||||
await loadRankPage(currentTabKey.value);
|
||||
};
|
||||
|
||||
// 实时记录当前榜单的滚动位置,切换回来时恢复到上一次浏览位置。
|
||||
const onScrollView = (e) => {
|
||||
const scrollTop = e.detail.scrollTop || 0;
|
||||
if (suppressScrollSync.value) return;
|
||||
syncScrollVisualState(scrollTop);
|
||||
rankStates.value[currentTabKey.value].scrollTop = scrollTop;
|
||||
};
|
||||
|
||||
// 计算 tab 在滚动内容中的真实位置和高度,作为吸顶切换的唯一依据。
|
||||
const measureTabsMetrics = () => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query
|
||||
.select("#rank-list-content-start")
|
||||
.boundingClientRect()
|
||||
.select(".rank-tabs-anchor")
|
||||
.boundingClientRect()
|
||||
.exec((res = []) => {
|
||||
const [startRect, rect] = res;
|
||||
if (!startRect || !rect) return;
|
||||
const tabOffset = rect.top - startRect.top;
|
||||
tabsStickyThreshold.value = Math.max(0, tabOffset - 92);
|
||||
tabsHeight.value = rect.height || 0;
|
||||
tabsStickyReady.value = true;
|
||||
syncScrollVisualState(currentScrollTop.value);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,18 +350,32 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
</view>
|
||||
<scroll-view
|
||||
scroll-y
|
||||
:scroll-with-animation="tabSwitchAnimating"
|
||||
:scroll-top="restoreScrollTop"
|
||||
@scroll="onScrollView"
|
||||
:style="{ height: myData.userId ? '90vh' : '100vh' }"
|
||||
@scrolltolower="loadMore"
|
||||
:style="{ height: user.id ? '90vh' : '100vh' }"
|
||||
>
|
||||
<view id="rank-list-content-start" class="content-start-anchor"></view>
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-09-25/dd1p9b3wcrwnlnghiq.png"
|
||||
mode="widthFix"
|
||||
class="header-bg"
|
||||
@load="measureTabsMetrics"
|
||||
/>
|
||||
<view class="rank-tabs">
|
||||
<view
|
||||
v-if="stickyTabsActive"
|
||||
class="rank-tabs-placeholder"
|
||||
:style="{ height: `${tabsHeight}px` }"
|
||||
/>
|
||||
<view
|
||||
class="rank-tabs rank-tabs-anchor"
|
||||
:class="{ 'rank-tabs-anchor-fixed': stickyTabsActive }"
|
||||
:style="stickyTabsActive ? { top: `${stickyTabsTop}px` } : {}"
|
||||
>
|
||||
<view
|
||||
v-for="(rankType, index) in ['积分榜', 'MVP榜', '十环榜']"
|
||||
:key="index"
|
||||
v-for="(rankType, index) in rankTabs"
|
||||
:key="rankType.key"
|
||||
:style="{
|
||||
fontSize: index === selectedIndex ? '16px' : '14px',
|
||||
color: index === selectedIndex ? '#000' : '#fff',
|
||||
@@ -94,18 +383,18 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
}"
|
||||
@tap="handleSelect(index)"
|
||||
>
|
||||
{{ rankType }}
|
||||
{{ rankType.title }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="rank-list">
|
||||
<view class="rank-list-header">
|
||||
<text>排名</text>
|
||||
<text>用户ID</text>
|
||||
<text>{{ subTitles[selectedIndex] }}</text>
|
||||
<text>{{ currentSubTitle }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(item, index) in currentList"
|
||||
:key="index"
|
||||
:key="`${currentTabKey}-${index}-${item.userId || item.name}`"
|
||||
class="rank-list-item"
|
||||
:style="{
|
||||
backgroundColor: index % 2 === 0 ? '#9898981f' : 'transparent',
|
||||
@@ -147,65 +436,60 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
src="../static/champ3.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-if="index > 2" class="view-crown">{{ index + 1 }}</view>
|
||||
<view v-if="index > 2" class="view-crown">
|
||||
{{ getDisplayRank(item, index) }}
|
||||
</view>
|
||||
<Avatar :src="item.avatar" />
|
||||
<view class="rank-item-content">
|
||||
<text class="truncate">{{ item.name }}</text>
|
||||
<text>{{ getLvlName(item.rankLvl) }},{{ item.TotalGames }}场</text>
|
||||
<text>{{ formatLevelText(item) }}</text>
|
||||
</view>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 0">
|
||||
<text class="rank-item-integral">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ item.totalScore }} </text
|
||||
>分
|
||||
</text>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 1">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ item.mvpCount }} </text
|
||||
>次
|
||||
</text>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 2">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ item.TenRings }} </text
|
||||
>次
|
||||
>
|
||||
{{ getRankValue(item) }}
|
||||
</text>
|
||||
{{ getRankUnit() }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="!currentList.length" class="no-data">
|
||||
<text>筹备中...</text>
|
||||
<view
|
||||
v-if="currentState.loading && !currentList.length"
|
||||
class="no-data"
|
||||
>
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<view
|
||||
v-else-if="!currentState.loading && !currentList.length"
|
||||
class="no-data"
|
||||
>
|
||||
<text>暂无数据</text>
|
||||
</view>
|
||||
<view v-else class="list-tip">
|
||||
<text v-if="currentState.loading">加载中...</text>
|
||||
<text v-else-if="currentState.noMore">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="my-rank-data" v-if="myData.userId">
|
||||
<view class="my-rank-data" v-if="currentMyData">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-08-05/dbuaf19pf7qd8ps0uh.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text>{{ myData.rank }}</text>
|
||||
<Avatar :src="user.avatar" />
|
||||
<text>{{ getDisplayMyRank(currentMyData) }}</text>
|
||||
<Avatar :src="currentMyData.avatar || user.avatar" />
|
||||
<view class="rank-item-content">
|
||||
<text class="truncate">{{ user.nickName }}</text>
|
||||
<text>{{ user.lvlName }},{{ myData.TotalGames }}场</text>
|
||||
<text class="truncate">{{ currentMyData.name || user.nickName }}</text>
|
||||
<text>{{ formatLevelText(currentMyData) }}</text>
|
||||
</view>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 0">
|
||||
<text class="rank-item-integral">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ myData.totalScore || 0 }}</text
|
||||
>分</text
|
||||
>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 1">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ myData.mvpCount || 0 }}</text
|
||||
>次</text
|
||||
>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 2">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ myData.TenRings || 0 }}</text
|
||||
>次</text
|
||||
>
|
||||
>
|
||||
{{ getRankValue(currentMyData) }}
|
||||
</text>
|
||||
{{ getRankUnit() }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -214,9 +498,16 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-start-anchor {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.header-bg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
@@ -227,6 +518,7 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-back {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -234,6 +526,7 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
margin-top: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header > image:first-child {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@@ -242,25 +535,40 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
left: 0;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.header > text {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
transition: all 0.5s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rank-tabs {
|
||||
width: calc(100% - 20px);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0 10px;
|
||||
margin-top: -15px;
|
||||
padding: 20rpx 10px;
|
||||
}
|
||||
|
||||
.rank-tabs > view {
|
||||
width: 25%;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.rank-tabs-placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rank-tabs-anchor-fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
z-index: 11;
|
||||
background: #000000;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -268,11 +576,12 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
width: calc(100% - 20px);
|
||||
color: #fff9;
|
||||
font-size: 12px;
|
||||
margin: 10px;
|
||||
margin: 0 10px 10px 10px;
|
||||
border: 1px solid rgb(255 217 71 / 0.2);
|
||||
border-radius: 10px;
|
||||
background-color: #313131;
|
||||
}
|
||||
|
||||
.rank-list > view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -281,20 +590,25 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rank-list-header {
|
||||
width: calc(100% - 20px) !important;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.rank-list-header > text:nth-child(2) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.rank-list-header > text:last-child {
|
||||
width: 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rank-list-item {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.player-bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -302,12 +616,14 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.player-crown {
|
||||
position: relative;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
margin: 0 15px;
|
||||
}
|
||||
|
||||
.view-crown {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
@@ -319,6 +635,7 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
background-color: #676767;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rank-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -328,17 +645,20 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
position: relative;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.rank-item-content > text:first-child {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
margin-bottom: 3px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.rank-list-item > text:last-child {
|
||||
margin-right: 10px;
|
||||
width: 56px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.my-rank-data {
|
||||
width: calc(100% - 30px);
|
||||
padding: 15px;
|
||||
@@ -352,12 +672,14 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.my-rank-data > image:first-child {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: -5px;
|
||||
}
|
||||
|
||||
.my-rank-data > text:nth-child(2) {
|
||||
background-color: #c1a434;
|
||||
position: relative;
|
||||
@@ -369,20 +691,24 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
min-width: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.my-rank-data > text:last-child {
|
||||
position: relative;
|
||||
margin-right: 10px;
|
||||
width: 65px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.my-rank-data > .rank-item-content > text:first-child {
|
||||
color: #fed847;
|
||||
}
|
||||
|
||||
.my-rank-data > .rank-item-integral {
|
||||
color: #fff9;
|
||||
font-size: 12px;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
@@ -392,4 +718,11 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
color: #fff9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.list-tip {
|
||||
justify-content: center !important;
|
||||
color: #fff9;
|
||||
font-size: 12px;
|
||||
min-height: 60rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
BIN
src/static/rank/battle-choose.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src/static/rank/battle10.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/static/rank/battle1v1.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src/static/rank/battle2v2.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/static/rank/battle3v3.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/static/rank/battle5.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/static/rank/bubble-tip.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/static/rank/rank-bg.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/static/rank/star.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/static/rank/triangle.png
Normal file
|
After Width: | Height: | Size: 208 B |