feat(dashboard): add per-user drill-down for group, model, and endpoint distributions

Click on a group name, model name, or endpoint name in the distribution
tables to expand and show per-user usage breakdown (requests, tokens,
actual cost, standard cost).

Backend: new GET /admin/dashboard/user-breakdown API with group_id,
model, endpoint, endpoint_type filters.
Frontend: clickable rows with expand/collapse sub-table in all three
distribution charts.
This commit is contained in:
erio
2026-03-16 21:31:52 +08:00
parent f42c8f2abe
commit 4b41e898a4
16 changed files with 474 additions and 74 deletions

View File

@@ -12,6 +12,7 @@ import type {
ApiKeyUsageTrendPoint,
UserUsageTrendPoint,
UserSpendingRankingResponse,
UserBreakdownItem,
UsageRequestType
} from '@/types'
@@ -156,6 +157,29 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta
return data
}
export interface UserBreakdownParams {
start_date?: string
end_date?: string
group_id?: number
model?: string
endpoint?: string
endpoint_type?: 'inbound' | 'upstream' | 'path'
limit?: number
}
export interface UserBreakdownResponse {
users: UserBreakdownItem[]
start_date: string
end_date: string
}
export async function getUserBreakdown(params: UserBreakdownParams): Promise<UserBreakdownResponse> {
const { data } = await apiClient.get<UserBreakdownResponse>('/admin/dashboard/user-breakdown', {
params
})
return data
}
/**
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
*/

View File

@@ -87,27 +87,40 @@
</tr>
</thead>
<tbody>
<tr
v-for="item in displayEndpointStats"
:key="item.endpoint"
class="border-t border-gray-100 dark:border-gray-700"
>
<td class="max-w-[180px] truncate py-1.5 font-medium text-gray-900 dark:text-white" :title="item.endpoint">
{{ item.endpoint }}
</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.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(item.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(item.cost) }}
</td>
</tr>
<template v-for="item in displayEndpointStats" :key="item.endpoint">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown(item.endpoint)"
>
<td class="max-w-[180px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" :title="item.endpoint">
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === item.endpoint" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ item.endpoint }}
</span>
</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.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(item.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(item.cost) }}
</td>
</tr>
<tr v-if="expandedKey === item.endpoint">
<td colspan="5" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -119,12 +132,14 @@
</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 { EndpointStat } from '@/types'
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
import type { EndpointStat, UserBreakdownItem } from '@/types'
import { getUserBreakdown } from '@/api/admin/dashboard'
ChartJS.register(ArcElement, Tooltip, Legend)
@@ -144,6 +159,8 @@ const props = withDefaults(
source?: EndpointSource
showMetricToggle?: boolean
showSourceToggle?: boolean
startDate?: string
endDate?: string
}>(),
{
upstreamEndpointStats: () => [],
@@ -162,6 +179,33 @@ const emit = defineEmits<{
'update:source': [value: EndpointSource]
}>()
const expandedKey = ref<string | null>(null)
const breakdownItems = ref<UserBreakdownItem[]>([])
const breakdownLoading = ref(false)
const toggleBreakdown = async (endpoint: string) => {
if (expandedKey.value === endpoint) {
expandedKey.value = null
return
}
expandedKey.value = endpoint
breakdownLoading.value = true
breakdownItems.value = []
try {
const res = await getUserBreakdown({
start_date: props.startDate,
end_date: props.endDate,
endpoint,
endpoint_type: props.source,
})
breakdownItems.value = res.users || []
} catch {
breakdownItems.value = []
} finally {
breakdownLoading.value = false
}
}
const chartColors = [
'#3b82f6',
'#10b981',

View File

@@ -37,7 +37,7 @@
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<div class="max-h-64 flex-1 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
@@ -49,30 +49,46 @@
</tr>
</thead>
<tbody>
<tr
v-for="group in displayGroupStats"
:key="group.group_id"
class="border-t border-gray-100 dark:border-gray-700"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title="group.group_name || String(group.group_id)"
<template v-for="group in displayGroupStats" :key="group.group_id">
<tr
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="group.group_id > 0 && toggleBreakdown('group', group.group_id)"
>
{{ group.group_name || t('admin.dashboard.noGroup') }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(group.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(group.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(group.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(group.cost) }}
</td>
</tr>
<td
class="max-w-[100px] truncate py-1.5 font-medium"
:class="group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:title="group.group_name || String(group.group_id)"
>
<span class="inline-flex items-center gap-1">
<svg v-if="group.group_id > 0 && expandedKey === `group-${group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="group.group_id > 0" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ group.group_name || t('admin.dashboard.noGroup') }}
</span>
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(group.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(group.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(group.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(group.cost) }}
</td>
</tr>
<!-- User breakdown sub-rows -->
<tr v-if="expandedKey === `group-${group.group_id}`">
<td colspan="5" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -87,12 +103,14 @@
</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 { GroupStat } from '@/types'
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
import type { GroupStat, UserBreakdownItem } from '@/types'
import { getUserBreakdown } from '@/api/admin/dashboard'
ChartJS.register(ArcElement, Tooltip, Legend)
@@ -105,6 +123,8 @@ const props = withDefaults(defineProps<{
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
startDate?: string
endDate?: string
}>(), {
loading: false,
metric: 'tokens',
@@ -115,6 +135,33 @@ const emit = defineEmits<{
'update:metric': [value: DistributionMetric]
}>()
const expandedKey = ref<string | null>(null)
const breakdownItems = ref<UserBreakdownItem[]>([])
const breakdownLoading = ref(false)
const toggleBreakdown = async (type: string, id: number | string) => {
const key = `${type}-${id}`
if (expandedKey.value === key) {
expandedKey.value = null
return
}
expandedKey.value = key
breakdownLoading.value = true
breakdownItems.value = []
try {
const res = await getUserBreakdown({
start_date: props.startDate,
end_date: props.endDate,
group_id: Number(id),
})
breakdownItems.value = res.users || []
} catch {
breakdownItems.value = []
} finally {
breakdownLoading.value = false
}
}
const chartColors = [
'#3b82f6',
'#10b981',

View File

@@ -71,7 +71,7 @@
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="max-h-48 flex-1 overflow-y-auto">
<div class="max-h-64 flex-1 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
@@ -83,30 +83,43 @@
</tr>
</thead>
<tbody>
<tr
v-for="model in displayModelStats"
:key="model.model"
class="border-t border-gray-100 dark:border-gray-700"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title="model.model"
<template v-for="model in displayModelStats" :key="model.model">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown('model', model.model)"
>
{{ model.model }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(model.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(model.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(model.cost) }}
</td>
</tr>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
:title="model.model"
>
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ model.model }}
</span>
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatNumber(model.requests) }}
</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
{{ formatTokens(model.total_tokens) }}
</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(model.cost) }}
</td>
</tr>
<tr v-if="expandedKey === `model-${model.model}`">
<td colspan="5" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
/>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -193,7 +206,9 @@ 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, UserSpendingRankingItem } from '@/types'
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
import type { ModelStat, UserSpendingRankingItem, UserBreakdownItem } from '@/types'
import { getUserBreakdown } from '@/api/admin/dashboard'
ChartJS.register(ArcElement, Tooltip, Legend)
@@ -213,6 +228,8 @@ const props = withDefaults(defineProps<{
showMetricToggle?: boolean
rankingLoading?: boolean
rankingError?: boolean
startDate?: string
endDate?: string
}>(), {
enableRankingView: false,
rankingItems: () => [],
@@ -226,6 +243,33 @@ const props = withDefaults(defineProps<{
rankingError: false
})
const expandedKey = ref<string | null>(null)
const breakdownItems = ref<UserBreakdownItem[]>([])
const breakdownLoading = ref(false)
const toggleBreakdown = async (type: string, id: string) => {
const key = `${type}-${id}`
if (expandedKey.value === key) {
expandedKey.value = null
return
}
expandedKey.value = key
breakdownLoading.value = true
breakdownItems.value = []
try {
const res = await getUserBreakdown({
start_date: props.startDate,
end_date: props.endDate,
model: id,
})
breakdownItems.value = res.users || []
} catch {
breakdownItems.value = []
} finally {
breakdownLoading.value = false
}
}
const emit = defineEmits<{
'update:metric': [value: DistributionMetric]
'ranking-click': [item: UserSpendingRankingItem]

View File

@@ -0,0 +1,71 @@
<template>
<div class="bg-gray-50/50 dark:bg-dark-700/30">
<div v-if="loading" class="flex items-center justify-center py-3">
<LoadingSpinner />
</div>
<div v-else-if="items.length === 0" class="py-2 text-center text-xs text-gray-400">
{{ t('admin.dashboard.noDataAvailable') }}
</div>
<table v-else class="w-full text-xs">
<thead>
<tr class="text-gray-400 dark:text-gray-500">
<th class="py-1 pl-6 text-left">{{ t('admin.dashboard.spendingRankingUser') }}</th>
<th class="py-1 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="py-1 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="py-1 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="py-1 pr-1 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in items"
:key="user.user_id"
class="border-t border-gray-100/50 dark:border-gray-700/50"
>
<td class="max-w-[120px] truncate py-1 pl-6 text-gray-600 dark:text-gray-300" :title="user.email">
{{ user.email || `User #${user.user_id}` }}
</td>
<td class="py-1 text-right text-gray-500 dark:text-gray-400">
{{ user.requests.toLocaleString() }}
</td>
<td class="py-1 text-right text-gray-500 dark:text-gray-400">
{{ formatTokens(user.total_tokens) }}
</td>
<td class="py-1 text-right text-green-600 dark:text-green-400">
${{ formatCost(user.actual_cost) }}
</td>
<td class="py-1 pr-1 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(user.cost) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { UserBreakdownItem } from '@/types'
const { t } = useI18n()
defineProps<{
items: UserBreakdownItem[]
loading?: boolean
}>()
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(2)}K`
return value.toLocaleString()
}
const formatCost = (value: number): string => {
if (value >= 1000) return (value / 1000).toFixed(2) + 'K'
if (value >= 1) return value.toFixed(2)
if (value >= 0.01) return value.toFixed(3)
return value.toFixed(4)
}
</script>

View File

@@ -1202,6 +1202,15 @@ export interface GroupStat {
actual_cost: number // 实际扣除
}
export interface UserBreakdownItem {
user_id: number
email: string
requests: number
total_tokens: number
cost: number
actual_cost: number
}
export interface UserUsageTrendPoint {
date: string
user_id: number

View File

@@ -246,6 +246,8 @@
:loading="chartsLoading"
:ranking-loading="rankingLoading"
:ranking-error="rankingError"
:start-date="startDate"
:end-date="endDate"
@ranking-click="goToUserUsage"
/>
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />

View File

@@ -18,12 +18,16 @@
:model-stats="modelStats"
:loading="chartsLoading"
:show-metric-toggle="true"
:start-date="startDate"
:end-date="endDate"
/>
<GroupDistributionChart
v-model:metric="groupDistributionMetric"
:group-stats="groupStats"
:loading="chartsLoading"
:show-metric-toggle="true"
:start-date="startDate"
:end-date="endDate"
/>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@@ -37,6 +41,8 @@
:show-source-toggle="true"
:show-metric-toggle="true"
:title="t('usage.endpointDistribution')"
:start-date="startDate"
:end-date="endDate"
/>
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>