feat(admin): add user spending ranking dashboard view

This commit is contained in:
Peter
2026-03-13 03:41:29 +08:00
parent 826090e099
commit 80d8d6c3bc
17 changed files with 591 additions and 39 deletions

View File

@@ -2,38 +2,72 @@
<div class="card p-4">
<div class="mb-4 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.dashboard.modelDistribution') }}
{{ !enableRankingView || activeView === 'model_distribution'
? t('admin.dashboard.modelDistribution')
: t('admin.dashboard.spendingRankingTitle') }}
</h3>
<div
v-if="showMetricToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'tokens'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:metric', 'tokens')"
<div class="flex items-center gap-2">
<div
v-if="showMetricToggle"
class="inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
{{ t('admin.dashboard.metricTokens') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'actual_cost'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:metric', 'actual_cost')"
>
{{ t('admin.dashboard.metricActualCost') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'tokens'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:metric', 'tokens')"
>
{{ t('admin.dashboard.metricTokens') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="metric === 'actual_cost'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:metric', 'actual_cost')"
>
{{ t('admin.dashboard.metricActualCost') }}
</button>
</div>
<div v-if="enableRankingView" class="inline-flex rounded-lg bg-gray-100 p-1 dark:bg-dark-800">
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="
activeView === 'model_distribution'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
"
@click="activeView = 'model_distribution'"
>
{{ t('admin.dashboard.viewModelDistribution') }}
</button>
<button
type="button"
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class="
activeView === 'spending_ranking'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
"
@click="activeView = 'spending_ranking'"
>
{{ t('admin.dashboard.viewSpendingRanking') }}
</button>
</div>
</div>
</div>
<div v-if="loading" class="flex h-48 items-center justify-center">
<div v-if="activeView === 'model_distribution' && loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="displayModelStats.length > 0 && chartData" class="flex items-center gap-6">
<div
v-else-if="activeView === 'model_distribution' && displayModelStats.length > 0 && chartData"
class="flex items-center gap-6"
>
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
@@ -77,6 +111,70 @@
</table>
</div>
</div>
<div
v-else-if="activeView === 'model_distribution'"
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
<div v-else-if="rankingLoading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div
v-else-if="rankingError"
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.failedToLoad') }}
</div>
<div v-else-if="rankingItems.length > 0 && rankingChartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut :data="rankingChartData" :options="rankingDoughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
<th class="pb-2 text-left">{{ t('admin.dashboard.spendingRankingUser') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingRequests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingTokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.spendingRankingSpend') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in rankingItems"
:key="`${item.user_id}-${index}`"
class="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="emit('ranking-click', item)"
>
<td class="py-1.5">
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0 text-[11px] font-semibold text-gray-500 dark:text-gray-400">
#{{ index + 1 }}
</span>
<span
class="block max-w-[140px] truncate font-medium text-gray-900 dark:text-white"
:title="getRankingUserLabel(item)"
>
{{ getRankingUserLabel(item) }}
</span>
</div>
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(item.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(item.tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(item.actual_cost) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-else
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
@@ -87,34 +185,47 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { ModelStat } from '@/types'
import type { ModelStat, UserSpendingRankingItem } from '@/types'
ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
type DistributionMetric = 'tokens' | 'actual_cost'
const props = withDefaults(defineProps<{
modelStats: ModelStat[]
enableRankingView?: boolean
rankingItems?: UserSpendingRankingItem[]
rankingTotalActualCost?: number
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
rankingLoading?: boolean
rankingError?: boolean
}>(), {
enableRankingView: false,
rankingItems: () => [],
rankingTotalActualCost: 0,
loading: false,
metric: 'tokens',
showMetricToggle: false,
rankingLoading: false,
rankingError: false
})
const emit = defineEmits<{
'update:metric': [value: DistributionMetric]
'ranking-click': [item: UserSpendingRankingItem]
}>()
const enableRankingView = computed(() => props.enableRankingView)
const activeView = ref<'model_distribution' | 'spending_ranking'>('model_distribution')
const chartColors = [
'#3b82f6',
'#10b981',
@@ -125,7 +236,9 @@ const chartColors = [
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16'
'#84cc16',
'#06b6d4',
'#a855f7'
]
const displayModelStats = computed(() => {
@@ -150,6 +263,31 @@ const chartData = computed(() => {
}
})
const rankingChartData = computed(() => {
if (!props.rankingItems?.length) return null
const rankedTotal = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedTotal, 0)
const labels = props.rankingItems.map((item, index) => `#${index + 1} ${getRankingUserLabel(item)}`)
const data = props.rankingItems.map((item) => item.actual_cost)
if (otherActualCost > 0.000001) {
labels.push(t('admin.dashboard.spendingRankingOther'))
data.push(otherActualCost)
}
return {
labels,
datasets: [
{
data,
backgroundColor: chartColors.slice(0, data.length),
borderWidth: 0
}
]
}
})
const doughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
@@ -173,6 +311,26 @@ const doughnutOptions = computed(() => ({
}
}))
const rankingDoughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.raw as number
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0'
return `${context.label}: $${formatCost(value)} (${percentage}%)`
}
}
}
}
}))
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
@@ -188,6 +346,11 @@ const formatNumber = (value: number): string => {
return value.toLocaleString()
}
const getRankingUserLabel = (item: UserSpendingRankingItem): string => {
if (item.email) return item.email
return t('admin.redeem.userPrefix', { id: item.user_id })
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'