feat(ranking): keep overall ranking cards aligned while loading

Show each leaderboard section in its final card shell during fetch,
with a shared dots placeholder instead of stacked spinners and a
jumping layout.
This commit is contained in:
digua committed 2026-08-20 00:33:17 +08:00
1 parent e5f35e2b2b
commit 39ccc63a39
8 files changed
+85 -64

No files matched your search

@@ -15,17 +15,23 @@ const props = defineProps<{
const memberActivity = ref<MemberActivity[]>([])
const availableYears = ref<number[]>([])
const isBaseLoading = ref(false)
async function loadBaseData() {
if (!props.sessionId) return
const dataService = useDataService()
const [members, years] = await Promise.all([
dataService.getMemberActivity(props.sessionId, props.timeFilter),
dataService.getAvailableYears(props.sessionId),
])
memberActivity.value = members
availableYears.value = years
isBaseLoading.value = true
try {
const dataService = useDataService()
const [members, years] = await Promise.all([
dataService.getMemberActivity(props.sessionId, props.timeFilter),
dataService.getAvailableYears(props.sessionId),
])
memberActivity.value = members
availableYears.value = years
} finally {
isBaseLoading.value = false
}
}
watch(
@@ -48,7 +54,7 @@ const seasonTitle = computed(() => {
return minYear === maxYear ? `${minYear} 赛季` : `${minYear}-${maxYear} 赛季`
}
return '全部赛季'
return isBaseLoading.value ? '赛季' : '全部赛季'
})
const rankingTimeFilter = computed(() => ({
@@ -103,6 +109,7 @@ const mainContentClass = computed(() => RANKING_LAYOUTS[widthMode.value].content
<ActivityRank
:session-id="props.sessionId"
:member-activity="memberActivity"
:base-loading="isBaseLoading"
:time-filter="rankingTimeFilter"
:global-top-n="globalTopN"
/>
@@ -4,14 +4,16 @@ import type { DragonKingAnalysis, CheckInAnalysis } from '@openchatlab/core'
import type { MemberActivity } from '@/types/analysis'
import { EChartRank } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { SectionCard, LoadingState, Tabs, TopNSelect } from '@/components/UI'
import { SectionCard, Tabs, TopNSelect } from '@/components/UI'
import { useDataService } from '@/services/data/service'
import type { TimeFilter } from '@openchatlab/shared-types'
import RankingLoadingBody from './RankingLoadingBody.vue'
const props = withDefaults(
defineProps<{
sessionId: string
memberActivity: MemberActivity[]
baseLoading?: boolean
timeFilter?: TimeFilter
/** 是否显示 TopN 选择器 */
showTopNSelect?: boolean
@@ -89,6 +91,11 @@ const loyaltyRankData = computed<RankItem[]>(() => {
}))
})
const showLoading = computed(() => {
if (activeTab.value === 'activity') return Boolean(props.baseLoading) && props.memberActivity.length === 0
return isLoading.value
})
const currentRankData = computed(() => {
switch (activeTab.value) {
case 'activity':
@@ -132,12 +139,12 @@ const cardDescription = computed(() => {
case 'activity':
return '按消息发送数量排名'
case 'dragon': {
const totalDays = dragonKingAnalysis.value?.totalDays ?? 0
return `每天发言最多的人+1(共 ${totalDays} 天)`
const totalDays = dragonKingAnalysis.value?.totalDays
return totalDays == null ? '每天发言最多的人+1' : `每天发言最多的人+1(共 ${totalDays} 天)`
}
case 'loyalty': {
const totalDays = checkInAnalysis.value?.totalDays ?? 0
return `累计发言天数排名(共 ${totalDays} 天)`
const totalDays = checkInAnalysis.value?.totalDays
return totalDays == null ? '累计发言天数排名' : `累计发言天数排名(共 ${totalDays} 天)`
}
default:
return ''
@@ -168,7 +175,7 @@ watch(
</div>
</template>
<LoadingState v-if="isLoading && (activeTab === 'dragon' || activeTab === 'loyalty')" text="正在加载数据..." />
<RankingLoadingBody v-if="showLoading" />
<template v-else>
<EChartRank
@@ -2,9 +2,10 @@
import { computed, ref, watch } from 'vue'
import type { CheckInAnalysis } from '@openchatlab/core'
import { EChartStreakRank } from '../charts'
import { SectionCard, LoadingState, EmptyState, Tabs, TopNSelect } from '@/components/UI'
import { SectionCard, EmptyState, Tabs, TopNSelect } from '@/components/UI'
import { useDataService } from '@/services/data/service'
import type { TimeFilter } from '@openchatlab/shared-types'
import RankingLoadingBody from './RankingLoadingBody.vue'
const props = withDefaults(
defineProps<{
@@ -70,13 +71,7 @@ watch(
<template>
<div id="streak-rank" class="scroll-mt-24">
<LoadingState v-if="isLoading" text="正在分析数据..." />
<SectionCard
v-else-if="analysis && analysis.streakRank.length > 0"
:title="streakTitle"
:description="streakDescription"
>
<SectionCard :title="streakTitle" :description="streakDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="topN" />
@@ -91,11 +86,16 @@ watch(
/>
</div>
</template>
<EChartStreakRank :items="analysis.streakRank" :title="streakTitle" :mode="streakMode" :top-n="topN" bare />
</SectionCard>
<SectionCard v-else title="🔥 火花榜">
<EmptyState text="暂无连续发言数据" />
<RankingLoadingBody v-if="isLoading" />
<EChartStreakRank
v-else-if="analysis && analysis.streakRank.length > 0"
:items="analysis.streakRank"
:title="streakTitle"
:mode="streakMode"
:top-n="topN"
bare
/>
<EmptyState v-else text="暂无连续发言数据" />
</SectionCard>
</div>
</template>
@@ -2,9 +2,10 @@
import { ref, watch } from 'vue'
import type { DivingAnalysis } from '@openchatlab/core'
import { EChartDivingRank } from '../charts'
import { LoadingState } from '@/components/UI'
import { EmptyState, SectionCard } from '@/components/UI'
import { useDataService } from '@/services/data/service'
import type { TimeFilter } from '@openchatlab/shared-types'
import RankingLoadingBody from './RankingLoadingBody.vue'
const props = defineProps<{
sessionId: string
@@ -36,10 +37,15 @@ watch(
</script>
<template>
<LoadingState v-if="isLoading" text="正在统计潜水数据..." />
<SectionCard v-if="isLoading" title="🤿 潜水榜 - 潜水最久" description="距离上次发言时间最久的成员">
<RankingLoadingBody />
</SectionCard>
<EChartDivingRank
v-else-if="analysis && analysis.rank.length > 0"
:items="analysis.rank"
:global-top-n="globalTopN"
/>
<SectionCard v-else title="🤿 潜水榜">
<EmptyState text="暂无潜水数据" />
</SectionCard>
</template>
@@ -3,10 +3,11 @@ import { computed, ref, watch } from 'vue'
import type { MemeBattleAnalysis } from '@openchatlab/core'
import { EChartRank } from '@/components/charts'
import type { RankItem } from '@/components/charts'
import { LoadingState, Tabs, SectionCard, TopNSelect } from '@/components/UI'
import { Tabs, SectionCard, TopNSelect } from '@/components/UI'
import { EChartBattleRank } from '../charts'
import { useDataService } from '@/services/data/service'
import type { TimeFilter } from '@openchatlab/shared-types'
import RankingLoadingBody from './RankingLoadingBody.vue'
const props = withDefaults(
defineProps<{
@@ -111,9 +112,7 @@ watch(
</script>
<template>
<LoadingState v-if="isLoading" text="正在统计斗图数据..." />
<SectionCard v-else-if="analysis" :title="cardTitle" :description="cardDescription">
<SectionCard v-if="isLoading || analysis" :title="cardTitle" :description="cardDescription">
<template #headerRight>
<div class="flex items-center gap-3">
<TopNSelect v-if="showTopNSelect" v-model="topN" />
@@ -129,8 +128,10 @@ watch(
</div>
</template>
<RankingLoadingBody v-if="isLoading" />
<!-- 史诗级战役视图 -->
<template v-if="activeTab === 'battle'">
<template v-else-if="analysis && activeTab === 'battle'">
<EChartBattleRank
v-if="analysis.topBattles.length > 0"
:battles="analysis.topBattles"
@@ -142,7 +143,7 @@ watch(
</template>
<!-- 按场次/按图量视图 -->
<template v-else>
<template v-else-if="analysis">
<EChartRank
v-if="currentRankData.length > 0"
:members="currentRankData"
@@ -2,10 +2,11 @@
import { computed, ref, watch } from 'vue'
import type { NightOwlAnalysis } from '@openchatlab/core'
import { EChartRank } from '@/components/charts'
import { SectionCard, Tabs, TopNSelect, LoadingState } from '@/components/UI'
import { SectionCard, Tabs, TopNSelect } from '@/components/UI'
import { EChartConsecutiveRank, EChartNightOwlRank } from '../charts'
import { useDataService } from '@/services/data/service'
import type { TimeFilter } from '@openchatlab/shared-types'
import RankingLoadingBody from './RankingLoadingBody.vue'
const props = withDefaults(
defineProps<{
@@ -85,10 +86,9 @@ const timeRankTitle = computed(() => {
// 时间排行描述
const timeRankDescription = computed(() => {
const totalDays = analysis.value?.totalDays ?? 0
return timeRankTab.value === 'last'
? `每天最后一个发言的人(共 ${totalDays} 天)`
: `每天第一个发言的人(共 ${totalDays} 天)`
const base = timeRankTab.value === 'last' ? '每天最后一个发言的人' : '每天第一个发言的人'
const totalDays = analysis.value?.totalDays
return totalDays == null ? base : `${base}(共 ${totalDays} 天)`
})
// 修仙统计标题
@@ -110,10 +110,7 @@ watch(
<template>
<div class="space-y-6">
<LoadingState v-if="isLoading" text="正在加载出勤分析..." />
<template v-else-if="analysis">
<!-- 修仙统计(发言分布 + 连续记录) -->
<template v-if="isLoading || analysis">
<SectionCard :title="nightStatsTitle" :description="nightStatsDescription">
<template #headerRight>
<div class="flex items-center gap-3">
@@ -129,10 +126,10 @@ watch(
</div>
</template>
<!-- 发言分布 -->
<template v-if="nightStatsTab === 'distribution'">
<RankingLoadingBody v-if="isLoading" />
<template v-else-if="nightStatsTab === 'distribution'">
<EChartNightOwlRank
v-if="analysis.nightOwlRank.length > 0"
v-if="analysis && analysis.nightOwlRank.length > 0"
:items="analysis.nightOwlRank"
:top-n="nightStatsTopN"
title=""
@@ -140,11 +137,9 @@ watch(
/>
<div v-else class="py-8 text-center text-sm text-gray-400">暂无深夜发言数据</div>
</template>
<!-- 连续记录 -->
<template v-else>
<EChartConsecutiveRank
v-if="analysis.consecutiveRecords.length > 0"
v-if="analysis && analysis.consecutiveRecords.length > 0"
:items="analysis.consecutiveRecords"
:top-n="nightStatsTopN"
title=""
@@ -154,7 +149,6 @@ watch(
</template>
</SectionCard>
<!-- 出勤排行(最早上班 + 最晚下班) -->
<SectionCard :title="timeRankTitle" :description="timeRankDescription">
<template #headerRight>
<div class="flex items-center gap-3">
@@ -170,8 +164,9 @@ watch(
</div>
</template>
<RankingLoadingBody v-if="isLoading" />
<EChartRank
v-if="currentTimeRankData.length > 0"
v-else-if="currentTimeRankData.length > 0"
:members="currentTimeRankData"
:title="timeRankTitle"
:top-n="timeRankTopN"
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { LoadingDots } from '@/components/UI'
</script>
<template>
<div class="flex h-56 items-center justify-center" role="status" aria-live="polite">
<LoadingDots />
</div>
</template>
@@ -3,10 +3,11 @@ import { computed, ref, watch } from 'vue'
import type { RepeatAnalysis } from '@openchatlab/core'
import { EChartRank, EChartBar } from '@/components/charts'
import type { RankItem, EChartBarData } from '@/components/charts'
import { SectionCard, EmptyState, LoadingState, Tabs, TopNSelect } from '@/components/UI'
import { SectionCard, EmptyState, Tabs, TopNSelect } from '@/components/UI'
import { EChartTimeRank } from '../charts'
import { useDataService } from '@/services/data/service'
import type { TimeFilter } from '@openchatlab/shared-types'
import RankingLoadingBody from './RankingLoadingBody.vue'
const props = withDefaults(
defineProps<{
@@ -157,10 +158,7 @@ watch(
<template>
<div class="space-y-6">
<LoadingState v-if="isLoading" text="正在分析复读数据..." />
<template v-else-if="analysis && analysis.totalRepeatChains > 0">
<!-- 复读榜主卡片 -->
<template v-if="isLoading || (analysis && analysis.totalRepeatChains > 0)">
<SectionCard :title="cardTitle" :description="cardDescription">
<template #headerRight>
<div class="flex items-center gap-3">
@@ -177,8 +175,9 @@ watch(
</div>
</template>
<RankingLoadingBody v-if="isLoading" />
<EChartRank
v-if="currentRankData.length > 0"
v-else-if="currentRankData.length > 0"
:members="currentRankData"
:title="cardTitle"
unit="次"
@@ -188,7 +187,6 @@ watch(
<EmptyState v-else text="暂无数据" />
</SectionCard>
<!-- 复读统计(最快反应 + 链长分布) -->
<SectionCard :title="statsTitle" :description="statsDescription">
<template #headerRight>
<div class="flex items-center gap-3">
@@ -204,10 +202,10 @@ watch(
</div>
</template>
<!-- 最快反应 -->
<template v-if="statsTab === 'fastest'">
<RankingLoadingBody v-if="isLoading" />
<template v-else-if="statsTab === 'fastest'">
<EChartTimeRank
v-if="analysis.fastestRepeaters && analysis.fastestRepeaters.length > 0"
v-if="analysis?.fastestRepeaters && analysis.fastestRepeaters.length > 0"
:items="analysis.fastestRepeaters"
:top-n="statsTopN"
title=""
@@ -215,8 +213,6 @@ watch(
/>
<EmptyState v-else text="暂无最快复读数据" />
</template>
<!-- 链长分布 -->
<template v-else>
<div class="px-3 py-2">
<EChartBar v-if="chainLengthChartData.labels.length > 0" :data="chainLengthChartData" :height="200" />