mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
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" />
|
||||
|
||||
Reference in new issue
Block a user