fix(admin): polish spending ranking and usage defaults

This commit is contained in:
Peter
2026-03-16 00:17:47 +08:00
parent 6da5fa01b9
commit 8147866c09
10 changed files with 148 additions and 23 deletions

View File

@@ -127,7 +127,7 @@
>
{{ t('admin.dashboard.failedToLoad') }}
</div>
<div v-else-if="rankingItems.length > 0 && rankingChartData" class="flex items-center gap-6">
<div v-else-if="rankingDisplayItems.length > 0 && rankingChartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut :data="rankingChartData" :options="rankingDoughnutOptions" />
</div>
@@ -143,21 +143,24 @@
</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)"
v-for="(item, index) in rankingDisplayItems"
:key="item.isOther ? 'others' : `${item.user_id}-${index}`"
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="item.isOther
? 'bg-gray-50/70 dark:bg-dark-700/20'
: 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40'"
@click="item.isOther ? undefined : 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 }}
{{ item.isOther ? 'Σ' : `#${index + 1}` }}
</span>
<span
class="block max-w-[140px] truncate font-medium text-gray-900 dark:text-white"
:title="getRankingUserLabel(item)"
:title="getRankingRowLabel(item)"
>
{{ getRankingUserLabel(item) }}
{{ getRankingRowLabel(item) }}
</span>
</div>
</td>
@@ -197,11 +200,14 @@ ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
type DistributionMetric = 'tokens' | 'actual_cost'
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
const props = withDefaults(defineProps<{
modelStats: ModelStat[]
enableRankingView?: boolean
rankingItems?: UserSpendingRankingItem[]
rankingTotalActualCost?: number
rankingTotalRequests?: number
rankingTotalTokens?: number
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
@@ -211,6 +217,8 @@ const props = withDefaults(defineProps<{
enableRankingView: false,
rankingItems: () => [],
rankingTotalActualCost: 0,
rankingTotalRequests: 0,
rankingTotalTokens: 0,
loading: false,
metric: 'tokens',
showMetricToggle: false,
@@ -266,14 +274,14 @@ 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)
const backgroundColor = chartColors.slice(0, props.rankingItems.length)
if (otherActualCost > 0.000001) {
if (otherRankingItem.value) {
labels.push(t('admin.dashboard.spendingRankingOther'))
data.push(otherActualCost)
data.push(otherRankingItem.value.actual_cost)
backgroundColor.push('#94a3b8')
}
return {
@@ -281,13 +289,43 @@ const rankingChartData = computed(() => {
datasets: [
{
data,
backgroundColor: chartColors.slice(0, data.length),
backgroundColor,
borderWidth: 0
}
]
}
})
const otherRankingItem = computed<RankingDisplayItem | null>(() => {
if (!props.rankingItems?.length) return null
const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
const rankedRequests = props.rankingItems.reduce((sum, item) => sum + item.requests, 0)
const rankedTokens = props.rankingItems.reduce((sum, item) => sum + item.tokens, 0)
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedActualCost, 0)
const otherRequests = Math.max((props.rankingTotalRequests || 0) - rankedRequests, 0)
const otherTokens = Math.max((props.rankingTotalTokens || 0) - rankedTokens, 0)
if (otherActualCost <= 0.000001 && otherRequests <= 0 && otherTokens <= 0) return null
return {
user_id: 0,
email: '',
actual_cost: otherActualCost,
requests: otherRequests,
tokens: otherTokens,
isOther: true
}
})
const rankingDisplayItems = computed<RankingDisplayItem[]>(() => {
if (!props.rankingItems?.length) return []
return otherRankingItem.value
? [...props.rankingItems, otherRankingItem.value]
: [...props.rankingItems]
})
const doughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
@@ -351,6 +389,11 @@ const getRankingUserLabel = (item: UserSpendingRankingItem): string => {
return t('admin.redeem.userPrefix', { id: item.user_id })
}
const getRankingRowLabel = (item: RankingDisplayItem): string => {
if (item.isOther) return t('admin.dashboard.spendingRankingOther')
return getRankingUserLabel(item)
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'

View File

@@ -5,6 +5,14 @@ import ModelDistributionChart from '../ModelDistributionChart.vue'
const messages: Record<string, string> = {
'admin.dashboard.modelDistribution': 'Model Distribution',
'admin.dashboard.spendingRankingTitle': 'User Spending Ranking',
'admin.dashboard.viewModelDistribution': 'Model Distribution',
'admin.dashboard.viewSpendingRanking': 'User Spending Ranking',
'admin.dashboard.spendingRankingUser': 'User',
'admin.dashboard.spendingRankingRequests': 'Requests',
'admin.dashboard.spendingRankingTokens': 'Tokens',
'admin.dashboard.spendingRankingSpend': 'Spend',
'admin.dashboard.spendingRankingOther': 'Others',
'admin.dashboard.model': 'Model',
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
@@ -13,6 +21,7 @@ const messages: Record<string, string> = {
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
'admin.dashboard.noDataAvailable': 'No data available',
'admin.redeem.userPrefix': 'User #{id}',
}
vi.mock('vue-i18n', async () => {
@@ -116,4 +125,47 @@ describe('ModelDistributionChart', () => {
})
expect(label).toBe('model-b: $1.40 (87.5%)')
})
it('renders Others in the spending ranking table and uses a dedicated chart color', async () => {
const wrapper = mount(ModelDistributionChart, {
props: {
modelStats: [],
enableRankingView: true,
rankingItems: [
{ user_id: 1, email: 'alpha@example.com', actual_cost: 12, requests: 10, tokens: 1000 },
{ user_id: 2, email: 'beta@example.com', actual_cost: 8, requests: 6, tokens: 600 },
],
rankingTotalActualCost: 30,
rankingTotalRequests: 20,
rankingTotalTokens: 2000,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
const rankingButton = wrapper.findAll('button').find((button) => button.text() === 'User Spending Ranking')
expect(rankingButton).toBeTruthy()
await rankingButton!.trigger('click')
const chartData = JSON.parse(wrapper.find('.chart-data').text())
expect(chartData.labels).toEqual([
'#1 alpha@example.com',
'#2 beta@example.com',
'Others',
])
expect(chartData.datasets[0].data).toEqual([12, 8, 10])
expect(chartData.datasets[0].backgroundColor[0]).toBe('#3b82f6')
expect(chartData.datasets[0].backgroundColor[2]).toBe('#94a3b8')
expect(chartData.datasets[0].backgroundColor[2]).not.toBe(chartData.datasets[0].backgroundColor[0])
const rows = wrapper.findAll('tbody tr')
expect(rows).toHaveLength(3)
expect(rows[2].text()).toContain('Others')
expect(rows[2].text()).toContain('4')
expect(rows[2].text()).toContain('400')
expect(rows[2].text()).toContain('$10.00')
})
})

View File

@@ -1199,6 +1199,8 @@ export interface UserSpendingRankingItem {
export interface UserSpendingRankingResponse {
ranking: UserSpendingRankingItem[]
total_actual_cost: number
total_requests: number
total_tokens: number
start_date: string
end_date: string
}

View File

@@ -241,6 +241,8 @@
:enable-ranking-view="true"
:ranking-items="rankingItems"
:ranking-total-actual-cost="rankingTotalActualCost"
:ranking-total-requests="rankingTotalRequests"
:ranking-total-tokens="rankingTotalTokens"
:loading="chartsLoading"
:ranking-loading="rankingLoading"
:ranking-error="rankingError"
@@ -334,6 +336,8 @@ const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([])
const rankingItems = ref<UserSpendingRankingItem[]>([])
const rankingTotalActualCost = ref(0)
const rankingTotalRequests = ref(0)
const rankingTotalTokens = ref(0)
let chartLoadSeq = 0
let usersTrendLoadSeq = 0
let rankingLoadSeq = 0
@@ -347,7 +351,7 @@ const formatLocalDate = (date: Date): string => {
const getTodayLocalDate = () => formatLocalDate(new Date())
// Date range
const granularity = ref<'day' | 'hour'>('day')
const granularity = ref<'day' | 'hour'>('hour')
const startDate = ref(getTodayLocalDate())
const endDate = ref(getTodayLocalDate())
@@ -630,11 +634,15 @@ const loadUserSpendingRanking = async () => {
if (currentSeq !== rankingLoadSeq) return
rankingItems.value = response.ranking || []
rankingTotalActualCost.value = response.total_actual_cost || 0
rankingTotalRequests.value = response.total_requests || 0
rankingTotalTokens.value = response.total_tokens || 0
} catch (error) {
if (currentSeq !== rankingLoadSeq) return
console.error('Error loading user spending ranking:', error)
rankingItems.value = []
rankingTotalActualCost.value = 0
rankingTotalRequests.value = 0
rankingTotalTokens.value = 0
rankingError.value = true
} finally {
if (currentSeq === rankingLoadSeq) {

View File

@@ -107,7 +107,7 @@ 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 trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour')
const modelDistributionMetric = ref<DistributionMetric>('tokens')
const groupDistributionMetric = ref<DistributionMetric>('tokens')
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
@@ -137,6 +137,7 @@ const formatLD = (d: Date) => {
return `${year}-${month}-${day}`
}
const getTodayLocalDate = () => formatLD(new Date())
const getGranularityForRange = (start: string, end: string): 'day' | 'hour' => start === end ? 'hour' : 'day'
const startDate = ref(getTodayLocalDate()); const endDate = ref(getTodayLocalDate())
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 })
@@ -171,6 +172,7 @@ const applyRouteQueryFilters = () => {
start_date: startDate.value,
end_date: endDate.value
}
granularity.value = getGranularityForRange(startDate.value, endDate.value)
}
const loadLogs = async () => {
@@ -224,7 +226,7 @@ const loadChartData = async () => {
}
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
const refreshData = () => { loadLogs(); loadStats(); loadChartData() }
const resetFilters = () => { startDate.value = getTodayLocalDate(); endDate.value = getTodayLocalDate(); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = 'day'; applyFilters() }
const resetFilters = () => { startDate.value = getTodayLocalDate(); endDate.value = getTodayLocalDate(); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = getGranularityForRange(startDate.value, endDate.value); applyFilters() }
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
const cancelExport = () => exportAbortController?.abort()