hookehuyr

feat(排行榜): 重构家庭排行榜页面并优化数据加载逻辑

重构家庭排行榜页面,使用真实API数据替代模拟数据
添加加载状态和暂无数据提示
优化区域切换逻辑,自动显示用户所在区县
改进步数显示格式,支持万单位显示
......@@ -46,7 +46,7 @@ export const getPointRangesAPI = (params) => fn(fetch.get(Api.POINT_RANGES, para
/**
* @description: 获取优惠券列表
* @param {Object} params - 查询参数
* @param {string} params.id - 优惠模块ID
* @param {string} params.category_id - 优惠模块ID
* @param {string} params.keyword - 搜索关键词(可选)
* @param {string} params.point_range - 积分范围(可选)
* @param {string} params.sort - 排序字段(可选)ASC=从小到大,DESC=从大到小
......
/*
* @Date: 2023-12-22 10:29:37
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-09 11:49:32
* @LastEditTime: 2025-09-09 13:11:17
* @FilePath: /lls_program/src/api/points.js
* @Description: 文件描述
*/
......@@ -49,9 +49,9 @@ export const collectPointAPI = (params) => fn(fetch.post(Api.COLLECT_POINT, para
export const getPointListAPI = (params) => fn(fetch.get(Api.POINT_LIST, params));
/**
* @description: 查询步数排行榜, 加上county参数查询的是相关区域的数据, 数据长度为10个, 不加上county参数查询的是上海数据, 数据长度是20个, 都是固定长度.
* @description: 查询步数排行榜, 数据长度为10个, 不加上county参数查询的是上海数据, 数据长度是20个, 都是固定长度.
* @param {Object} params - 请求参数
* @param {string} params.county - 区县
* @param {string} params.current_country - 是否只查我的当前家庭所在区县的排行榜。1=是,0=否。默认为否
* @returns {Object} response - 响应对象
* @returns {number} response.code - 响应状态码
* @returns {string} response.msg - 响应消息
......@@ -71,5 +71,6 @@ export const getPointListAPI = (params) => fn(fetch.get(Api.POINT_LIST, params))
* @returns {string} response.data.current_family[].created_by_nickname - 创建人昵称
* @returns {number} response.data.current_family[].step - 步数
* @returns {number} response.data.current_family[].rank - 排名
* @returns {number} response.data.current_family[].country - 区县
*/
export const getStepLeaderboardAPI = (params) => fn(fetch.get(Api.STEP_LEADERBOARD, params));
......
<!--
* @Date: 2025-09-01 13:07:52
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-09 11:40:05
* @LastEditTime: 2025-09-09 14:07:23
* @FilePath: /lls_program/src/pages/FamilyRank/index.vue
* @Description: 文件描述
-->
......@@ -16,150 +16,174 @@
:class="{ 'indicator-shanghai': activeTab === 'shanghai' }"
></view>
<view
v-for="region in availableRegions.slice(0, 2)"
:key="region.value"
class="tab-item"
:class="{ active: activeTab === 'huangpu' }"
@click="switchTab('huangpu')"
:class="{ active: activeTab === region.value }"
@click="switchTab(region.value)"
>
黄埔榜
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'shanghai' }"
@click="switchTab('shanghai')"
>
上海榜
{{ region.text === '上海市' ? '上海榜' : region.text.replace('区', '榜') }}
</view>
</view>
</view>
<!-- 排行榜内容 -->
<view class="rank-content" :class="{ 'content-switching': isContentSwitching }">
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<view class="loading-text">加载中...</view>
</view>
<!-- 前三名展示 -->
<view class="top-three">
<view v-else-if="topThreeData.length > 0" class="top-three">
<!-- 第二名 -->
<view class="rank-item second">
<view v-if="topThreeData[1]" class="rank-item second">
<view class="crown crown-silver">👑</view>
<view class="avatar">
<image :src="topRanks[1]?.avatar" class="avatar-img" mode="aspectFill" />
<image :src="topThreeData[1]?.avatar_url || defaultAvatar" class="avatar-img" mode="aspectFill" />
</view>
<view class="family-name">{{ topRanks[1]?.familyName }}</view>
<view class="leader-name">大家长:{{ topRanks[1]?.leaderName }}</view>
<view class="family-name">{{ topThreeData[1]?.name }}</view>
<view class="leader-name">大家长:{{ topThreeData[1]?.created_by_nickname }}</view>
<view class="rank-number">
<view class="rank-num">2</view>
<view class="steps-in-rank">{{ formatSteps(topRanks[1]?.steps) }}</view>
<view class="steps-in-rank">{{ formatSteps(topThreeData[1]?.step) }}</view>
</view>
</view>
<!-- 第一名 -->
<view class="rank-item first">
<view v-if="topThreeData[0]" class="rank-item first">
<view class="crown crown-gold">👑</view>
<view class="avatar">
<image :src="topRanks[0]?.avatar" class="avatar-img" mode="aspectFill" />
<image :src="topThreeData[0]?.avatar_url || defaultAvatar" class="avatar-img" mode="aspectFill" />
</view>
<view class="family-name">{{ topRanks[0]?.familyName }}</view>
<view class="leader-name">大家长:{{ topRanks[0]?.leaderName }}</view>
<view class="family-name">{{ topThreeData[0]?.name }}</view>
<view class="leader-name">大家长:{{ topThreeData[0]?.created_by_nickname }}</view>
<view class="rank-number">
<view class="rank-num">1</view>
<view class="steps-in-rank">{{ formatSteps(topRanks[0]?.steps) }}</view>
<view class="steps-in-rank">{{ formatSteps(topThreeData[0]?.step) }}</view>
</view>
</view>
<!-- 第三名 -->
<view class="rank-item third">
<view v-if="topThreeData[2]" class="rank-item third">
<view class="crown crown-bronze">👑</view>
<view class="avatar">
<image :src="topRanks[2]?.avatar" class="avatar-img" mode="aspectFill" />
<image :src="topThreeData[2]?.avatar_url || defaultAvatar" class="avatar-img" mode="aspectFill" />
</view>
<view class="family-name">{{ topRanks[2]?.familyName }}</view>
<view class="leader-name">大家长:{{ topRanks[2]?.leaderName }}</view>
<view class="family-name">{{ topThreeData[2]?.name }}</view>
<view class="leader-name">大家长:{{ topThreeData[2]?.created_by_nickname }}</view>
<view class="rank-number">
<view class="rank-num">3</view>
<view class="steps-in-rank">{{ formatSteps(topRanks[2]?.steps) }}</view>
<view class="steps-in-rank">{{ formatSteps(topThreeData[2]?.step) }}</view>
</view>
</view>
</view>
<!-- 其他排名列表 -->
<view class="rank-list">
<view v-if="otherRankData.length > 0" class="rank-list">
<view
v-for="(item, index) in otherRanks"
:key="index"
v-for="(item, index) in otherRankData"
:key="item.family_id || index"
class="rank-list-item"
>
<view class="rank-info">
<view class="rank-num">{{ item.rank }}</view>
<view class="rank-num">{{ index + 4 }}</view>
<view class="avatar-small">
<image :src="item.avatar" class="avatar-small-img" />
<image :src="item.avatar_url || defaultAvatar" class="avatar-small-img" mode="aspectFill" />
</view>
<view class="family-info">
<view class="family-name-small">{{ item.familyName }}</view>
<view class="leader-name-small">大家长:{{ item.leaderName }}</view>
<view class="family-name-small">{{ item.name }}</view>
<view class="leader-name-small">大家长:{{ item.created_by_nickname }}</view>
</view>
</view>
<view class="steps-info">
<view class="steps">{{ formatStepsForList(item.steps) }}</view>
<view class="steps">{{ formatStepsForList(item.step) }}</view>
</view>
</view>
</view>
<!-- 暂无数据 -->
<view v-else-if="!loading" class="no-data">
<view class="no-data-text">暂无{{ currentRegionName }}排行榜数据</view>
</view>
</view>
<!-- 我的排名悬浮卡片 -->
<view class="my-rank-card">
<view v-if="myRankInfo" class="my-rank-card">
<view class="my-rank-content">
<view class="my-rank-left">
<view class="my-rank-number">{{ myRank.rank }}+</view>
<view class="my-rank-number">
{{ myRankInfo.isNotRanked ? '未上榜' : (myRankInfo.rank > 99 ? myRankInfo.rank + '+' : myRankInfo.rank) }}
</view>
<view class="my-avatar">
<image :src="myRank.avatar" class="my-avatar-img" mode="aspectFill" />
<image :src="myRankInfo.avatar_url || defaultAvatar" class="my-avatar-img" mode="aspectFill" />
</view>
<view class="my-family-info">
<view class="my-family-name">{{ myRank.familyName }}</view>
<view class="my-leader-name">大家长:{{ myRank.leaderName }}</view>
<view class="my-family-name">{{ myRankInfo.family_name }}</view>
<view class="my-leader-name">大家长:{{ myRankInfo.created_by_nickname }}</view>
</view>
</view>
<view class="my-rank-right">
<view class="my-steps">{{ formatStepsForList(myRank.steps) }}</view>
<view class="rank-status">{{ myRank.status }}</view>
<view class="my-steps">{{ formatStepsForList(myRankInfo.step) }}</view>
<view class="rank-status">{{ myRankInfo.isNotRanked ? '未上榜' : '已上榜' }}</view>
</view>
</view>
</view>
<!-- 返回顶部组件 -->
<BackToTop :distance="200" />
<!-- <BackToTop :distance="200" /> -->
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import BackToTop from '@/components/BackToTop.vue'
// 默认头像
const defaultAvatar = 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'
// 导入接口
import { getStepLeaderboardAPI } from '@/api/points'
import { getFamilyInfoAPI } from '@/api/family'
// 区域信息
import { SHANGHAI_REGION } from '@/utils/config'
import { SHANGHAI_REGION as shanghaiRegion } from '@/utils/config'
const shanghaiRegionOptions = computed(() => {
return shanghaiRegion.map(item => ({
text: item.text,
value: String(item.value)
}))
})
// 当前激活的tab
const activeTab = ref('huangpu')
const activeTab = ref('')
// 内容切换状态
const isContentSwitching = ref(false)
// 排行榜数据
const leaderboardData = ref(null)
// 加载状态
const loading = ref(false)
/**
* 切换tab
* @param {string} tab - tab名称
*/
const switchTab = (tab) => {
const switchTab = async (tab) => {
if (activeTab.value === tab) return
// 开始切换动画
isContentSwitching.value = true
// 延迟切换内容,让淡出动画先执行
setTimeout(() => {
setTimeout(async () => {
activeTab.value = tab
// 重新加载排行榜数据
await loadLeaderboardData(false)
// 内容切换后,结束切换状态,开始淡入动画
setTimeout(() => {
isContentSwitching.value = false
......@@ -173,7 +197,10 @@ const switchTab = (tab) => {
* @returns {string} 格式化后的步数
*/
const formatSteps = (steps) => {
return steps ? steps.toLocaleString() : '0'
if (steps >= 10000) {
return (steps / 10000).toFixed(1) + '万'
}
return steps?.toString()
}
/**
......@@ -187,85 +214,128 @@ const formatStepsForList = (steps) => {
// 前三名数据
const topRanks = ref([
{
rank: 1,
familyName: '明媚的晴',
leaderName: '张明',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 45670
},
{
rank: 2,
familyName: '甜心小桃',
leaderName: '李桃',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 42350
},
{
rank: 3,
familyName: '真心找爱',
leaderName: '王真',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 38920
/**
* 加载排行榜数据
* @param {boolean} isInitialLoad - 是否为初始加载,避免无限递归
*/
const loadLeaderboardData = async (isInitialLoad = false) => {
try {
loading.value = true
const params = {}
// 添加current_country参数:1=是,0=否,默认为否
// 根据activeTab动态设置:上海榜时为0,区域榜时为1
params.current_country = activeTab.value === 'shanghai' ? '0' : '1'
const response = await getStepLeaderboardAPI(params)
if (response.code) {
leaderboardData.value = response.data
// 只在初始加载时从current_family.county获取区县信息,设置默认tab
if (isInitialLoad && response.data.current_family) {
const currentFamilyCounty = response.data.current_family.county;
if (currentFamilyCounty && String(currentFamilyCounty) !== activeTab.value) {
// 只在county与当前activeTab不同时才设置,确保county字段为字符串格式
activeTab.value = String(currentFamilyCounty)
// 设置activeTab后需要重新加载数据以获取正确的区县排行榜
await loadLeaderboardData(false)
return
}
}
}
} catch (error) {
console.error('获取排行榜数据失败:', error)
} finally {
loading.value = false
}
])
// 其他排名数据
const otherRanks = ref([
{
rank: 4,
familyName: '藏点理想者',
leaderName: '陈理',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 35010
},
{
rank: 5,
familyName: '熊熊熊很大',
leaderName: '熊大',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 32780
},
{
rank: 6,
familyName: '夏花流年❤️',
leaderName: '夏花',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 28450
},
{
rank: 7,
familyName: '给大家拜个早年',
leaderName: '拜年',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 25890
},
{
rank: 8,
familyName: '寻一人觅真心',
leaderName: '觅心',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 23800
},
{
rank: 9,
familyName: '大咪花💕',
leaderName: '大咪',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 21669
}
/**
* 计算当前区域的中文名称
*/
const currentRegionName = computed(() => {
if (activeTab.value === 'shanghai') {
return '上海市'
}
])
// 我的排名信息
const myRank = ref({
rank: 99,
familyName: '和谐之家',
leaderName: '陈家明',
avatar: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
steps: 8920,
status: '未上榜'
const region = shanghaiRegionOptions.value.find(item => item.value === activeTab.value)
return region ? region.text : '黄浦区'
})
/**
* 计算可用的区域选项
*/
const availableRegions = computed(() => {
// 从current_family.county获取区县信息,优先显示用户区域,然后是上海市
const currentFamilyCounty = leaderboardData.value?.current_family?.county
if (currentFamilyCounty) {
// 确保county字段为字符串格式进行比较
const userCounty = String(currentFamilyCounty)
// value值需要转成字符串进行比较
const userRegion = shanghaiRegionOptions.value.find(item => item.value === userCounty)
if (userRegion) {
// 用户区域在第一位,上海市在第二位
return [userRegion, { text: '上海市', value: 'shanghai' }]
}
}
// 默认显示黄浦区和上海市
return [
{ text: '黄浦区', value: '310101' },
{ text: '上海市', value: 'shanghai' }
]
})
/**
* 计算前三名数据
*/
const topThreeData = computed(() => {
if (!leaderboardData.value || !leaderboardData.value.families) {
return []
}
return leaderboardData.value.families.slice(0, 3)
})
/**
* 计算其他排名数据
*/
const otherRankData = computed(() => {
if (!leaderboardData.value || !leaderboardData.value.families) {
return []
}
return leaderboardData.value.families.slice(3)
})
/**
* 计算我的排名信息
*/
const myRankInfo = computed(() => {
if (!leaderboardData.value || !leaderboardData.value.current_family) {
return null
}
const currentFamily = leaderboardData.value.current_family
// 如果没有排名信息,返回未上榜状态
if (!currentFamily.rank || currentFamily.rank === 0) {
return {
...currentFamily,
rank: 0,
isNotRanked: true
}
}
return {
...currentFamily,
isNotRanked: false
}
})
/**
* 页面初始化
*/
onMounted(async () => {
// 直接加载排行榜数据,使用current_country参数获取当前家庭所在区县信息
await loadLeaderboardData(true)
})
</script>
......@@ -573,6 +643,32 @@ const myRank = ref({
}
}
/* 加载状态 */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.loading-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
/* 暂无数据 */
.no-data {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.no-data-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
.my-rank-card {
position: fixed;
bottom: 40rpx;
......
......@@ -278,7 +278,7 @@ const fetchCouponList = async (reset = false) => {
sort: sortOrder.value.toUpperCase(),
page: reset ? 0 : currentPage.value,
limit: 10,
id: pageParams.value.id || undefined
category_id: pageParams.value.id || undefined
};
const { code, data } = await getCouponListAPI(params);
......