Merge pull request #960 from 0xObjc/codex/user-spending-ranking

feat(admin): add user spending ranking dashboard view
This commit is contained in:
Wesley Liddick
2026-03-13 23:06:30 +08:00
committed by GitHub
17 changed files with 591 additions and 39 deletions

View File

@@ -11,6 +11,7 @@ import type {
GroupStat,
ApiKeyUsageTrendPoint,
UserUsageTrendPoint,
UserSpendingRankingResponse,
UsageRequestType
} from '@/types'
@@ -201,6 +202,11 @@ export interface UserTrendResponse {
granularity: string
}
export interface UserSpendingRankingParams
extends Pick<TrendParams, 'start_date' | 'end_date'> {
limit?: number
}
/**
* Get user usage trend data
* @param params - Query parameters for filtering
@@ -213,6 +219,20 @@ export async function getUserUsageTrend(params?: UserTrendParams): Promise<UserT
return data
}
/**
* Get user spending ranking data
* @param params - Query parameters for filtering
* @returns User spending ranking data
*/
export async function getUserSpendingRanking(
params?: UserSpendingRankingParams
): Promise<UserSpendingRankingResponse> {
const { data } = await apiClient.get<UserSpendingRankingResponse>('/admin/dashboard/users-ranking', {
params
})
return data
}
export interface BatchUserUsageStats {
user_id: number
today_actual_cost: number
@@ -271,6 +291,7 @@ export const dashboardAPI = {
getSnapshotV2,
getApiKeyUsageTrend,
getUserUsageTrend,
getUserSpendingRanking,
getBatchUsersUsage,
getBatchApiKeysUsage
}

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'

View File

@@ -963,6 +963,18 @@ export default {
standard: 'Standard',
noDataAvailable: 'No data available',
recentUsage: 'Recent Usage',
viewModelDistribution: 'Model Distribution',
viewSpendingRanking: 'User Spending Ranking',
spendingRankingTitle: 'User Spending Ranking',
spendingRankingUser: 'User',
spendingRankingRequests: 'Requests',
spendingRankingTokens: 'Tokens',
spendingRankingSpend: 'Spend',
spendingRankingOther: 'Others',
spendingRankingUsage: 'Usage',
spendShort: 'Spend',
requestsShort: 'Req',
tokensShort: 'Tok',
failedToLoad: 'Failed to load dashboard statistics'
},

View File

@@ -974,6 +974,18 @@ export default {
tokens: 'Token',
cache: '缓存',
recentUsage: '最近使用',
viewModelDistribution: '模型分布',
viewSpendingRanking: '用户消费榜',
spendingRankingTitle: '用户消费榜',
spendingRankingUser: '用户',
spendingRankingRequests: '请求',
spendingRankingTokens: 'Token',
spendingRankingSpend: '消费',
spendingRankingOther: '其他',
spendingRankingUsage: '用量',
spendShort: '消费',
requestsShort: '请求',
tokensShort: 'Token',
last7Days: '近 7 天',
noUsageRecords: '暂无使用记录',
startUsingApi: '开始使用 API 后,使用历史将显示在这里。',

View File

@@ -1162,6 +1162,21 @@ export interface UserUsageTrendPoint {
actual_cost: number // 实际扣除
}
export interface UserSpendingRankingItem {
user_id: number
email: string
actual_cost: number
requests: number
tokens: number
}
export interface UserSpendingRankingResponse {
ranking: UserSpendingRankingItem[]
total_actual_cost: number
start_date: string
end_date: string
}
export interface ApiKeyUsageTrendPoint {
date: string
api_key_id: number

View File

@@ -236,7 +236,16 @@
<!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ModelDistributionChart :model-stats="modelStats" :loading="chartsLoading" />
<ModelDistributionChart
:model-stats="modelStats"
:enable-ranking-view="true"
:ranking-items="rankingItems"
:ranking-total-actual-cost="rankingTotalActualCost"
:loading="chartsLoading"
:ranking-loading="rankingLoading"
:ranking-error="rankingError"
@ranking-click="goToUserUsage"
/>
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>
@@ -267,11 +276,18 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
const { t } = useI18n()
import { adminAPI } from '@/api/admin'
import type { DashboardStats, TrendDataPoint, ModelStat, UserUsageTrendPoint } from '@/types'
import type {
DashboardStats,
TrendDataPoint,
ModelStat,
UserUsageTrendPoint,
UserSpendingRankingItem
} from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -286,7 +302,6 @@ import {
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
@@ -299,24 +314,30 @@ ChartJS.register(
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
const appStore = useAppStore()
const router = useRouter()
const stats = ref<DashboardStats | null>(null)
const loading = ref(false)
const chartsLoading = ref(false)
const userTrendLoading = ref(false)
const rankingLoading = ref(false)
const rankingError = ref(false)
// Chart data
const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([])
const rankingItems = ref<UserSpendingRankingItem[]>([])
const rankingTotalActualCost = ref(0)
let chartLoadSeq = 0
let usersTrendLoadSeq = 0
let rankingLoadSeq = 0
const rankingLimit = 12
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
@@ -505,6 +526,17 @@ const formatDuration = (ms: number): string => {
return `${Math.round(ms)}ms`
}
const goToUserUsage = (item: UserSpendingRankingItem) => {
void router.push({
path: '/admin/usage',
query: {
user_id: String(item.user_id),
start_date: startDate.value,
end_date: endDate.value
}
})
}
// Date range change handler
const onDateRangeChange = (range: {
startDate: string
@@ -585,14 +617,46 @@ const loadUsersTrend = async () => {
}
}
const loadUserSpendingRanking = async () => {
const currentSeq = ++rankingLoadSeq
rankingLoading.value = true
rankingError.value = false
try {
const response = await adminAPI.dashboard.getUserSpendingRanking({
start_date: startDate.value,
end_date: endDate.value,
limit: rankingLimit
})
if (currentSeq !== rankingLoadSeq) return
rankingItems.value = response.ranking || []
rankingTotalActualCost.value = response.total_actual_cost || 0
} catch (error) {
if (currentSeq !== rankingLoadSeq) return
console.error('Error loading user spending ranking:', error)
rankingItems.value = []
rankingTotalActualCost.value = 0
rankingError.value = true
} finally {
if (currentSeq === rankingLoadSeq) {
rankingLoading.value = false
}
}
}
const loadDashboardStats = async () => {
await loadDashboardSnapshot(true)
void loadUsersTrend()
await Promise.all([
loadDashboardSnapshot(true),
loadUsersTrend(),
loadUserSpendingRanking()
])
}
const loadChartData = async () => {
await loadDashboardSnapshot(false)
void loadUsersTrend()
await Promise.all([
loadDashboardSnapshot(false),
loadUsersTrend(),
loadUserSpendingRanking()
])
}
onMounted(() => {

View File

@@ -89,6 +89,7 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import { formatReasoningEffort } from '@/utils/format'
import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType'
@@ -104,7 +105,7 @@ import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } f
const { t } = useI18n()
const appStore = useAppStore()
type DistributionMetric = 'tokens' | 'actual_cost'
const route = useRoute()
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
const modelDistributionMetric = ref<DistributionMetric>('tokens')
@@ -140,6 +141,38 @@ const startDate = ref(getTodayLocalDate()); const endDate = ref(getTodayLocalDat
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, request_type: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const getSingleQueryValue = (value: string | null | Array<string | null> | undefined): string | undefined => {
if (Array.isArray(value)) return value.find((item): item is string => typeof item === 'string' && item.length > 0)
return typeof value === 'string' && value.length > 0 ? value : undefined
}
const getNumericQueryValue = (value: string | null | Array<string | null> | undefined): number | undefined => {
const raw = getSingleQueryValue(value)
if (!raw) return undefined
const parsed = Number(raw)
return Number.isFinite(parsed) ? parsed : undefined
}
const applyRouteQueryFilters = () => {
const queryStartDate = getSingleQueryValue(route.query.start_date)
const queryEndDate = getSingleQueryValue(route.query.end_date)
const queryUserId = getNumericQueryValue(route.query.user_id)
if (queryStartDate) {
startDate.value = queryStartDate
}
if (queryEndDate) {
endDate.value = queryEndDate
}
filters.value = {
...filters.value,
user_id: queryUserId,
start_date: startDate.value,
end_date: endDate.value
}
}
const loadLogs = async () => {
abortController?.abort(); const c = new AbortController(); abortController = c; loading.value = true
try {
@@ -329,6 +362,7 @@ const handleColumnClickOutside = (event: MouseEvent) => {
}
onMounted(() => {
applyRouteQueryFilters()
loadLogs()
loadStats()
window.setTimeout(() => {