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

@@ -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 => {
@@ -502,6 +523,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
@@ -582,14 +614,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(formatLD(weekAgo)); const endDate = ref(formatLD(now))
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(() => {