feat(dashboard): add group usage distribution chart to usage page

Add a doughnut chart showing usage statistics broken down by group on
the admin usage records page. The chart appears alongside the existing
model distribution chart (2-column grid), with the token usage trend
chart moved to a separate full-width row below.

Changes:
- backend/pkg/usagestats: add GroupStat type
- backend/service: add GetGroupStatsWithFilters interface method and implementation
- backend/repository: implement GetGroupStatsWithFilters with LEFT JOIN groups
- backend/handler: add GetGroupStats handler with full filter support
- backend/routes: register GET /admin/dashboard/groups route
- backend/tests: add GetGroupStatsWithFilters stubs to contract/sora tests
- frontend/types: add GroupStat interface
- frontend/api: add getGroupStats API function and types
- frontend/components: add GroupDistributionChart.vue doughnut chart
- frontend/views: update UsageView layout and load group stats in parallel
- frontend/i18n: add groupDistribution, group, noGroup keys (zh + en)
This commit is contained in:
erio
2026-03-01 20:10:51 +08:00
parent 2129584fd6
commit 65459a99b6
14 changed files with 379 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ import type {
DashboardStats,
TrendDataPoint,
ModelStat,
GroupStat,
ApiKeyUsageTrendPoint,
UserUsageTrendPoint,
UsageRequestType
@@ -101,6 +102,34 @@ export async function getModelStats(params?: ModelStatsParams): Promise<ModelSta
return data
}
export interface GroupStatsParams {
start_date?: string
end_date?: string
user_id?: number
api_key_id?: number
account_id?: number
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
}
export interface GroupStatsResponse {
groups: GroupStat[]
start_date: string
end_date: string
}
/**
* Get group usage statistics
* @param params - Query parameters for filtering
* @returns Group usage statistics
*/
export async function getGroupStats(params?: GroupStatsParams): Promise<GroupStatsResponse> {
const { data } = await apiClient.get<GroupStatsResponse>('/admin/dashboard/groups', { params })
return data
}
export interface ApiKeyTrendParams extends TrendParams {
limit?: number
}
@@ -203,6 +232,7 @@ export const dashboardAPI = {
getRealtimeMetrics,
getUsageTrend,
getModelStats,
getGroupStats,
getApiKeyUsageTrend,
getUserUsageTrend,
getBatchUsersUsage,

View File

@@ -0,0 +1,152 @@
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.dashboard.groupDistribution') }}
</h3>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="groupStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</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.group') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="group in groupStats"
: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)"
>
{{ 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>
</tbody>
</table>
</div>
</div>
<div
v-else
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } 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'
ChartJS.register(ArcElement, Tooltip, Legend)
const { t } = useI18n()
const props = defineProps<{
groupStats: GroupStat[]
loading?: boolean
}>()
const chartColors = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16'
]
const chartData = computed(() => {
if (!props.groupStats?.length) return null
return {
labels: props.groupStats.map((g) => g.group_name || String(g.group_id)),
datasets: [
{
data: props.groupStats.map((g) => g.total_tokens),
backgroundColor: chartColors.slice(0, props.groupStats.length),
borderWidth: 0
}
]
}
})
const doughnutOptions = 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 = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
}
}
}
}
}))
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
</script>

View File

@@ -408,9 +408,12 @@ export default {
day: 'Day',
hour: 'Hour',
modelDistribution: 'Model Distribution',
groupDistribution: 'Group Usage Distribution',
tokenUsageTrend: 'Token Usage Trend',
noDataAvailable: 'No data available',
model: 'Model',
group: 'Group',
noGroup: 'No Group',
requests: 'Requests',
tokens: 'Tokens',
actual: 'Actual',
@@ -832,9 +835,12 @@ export default {
day: 'Day',
hour: 'Hour',
modelDistribution: 'Model Distribution',
groupDistribution: 'Group Usage Distribution',
tokenUsageTrend: 'Token Usage Trend',
userUsageTrend: 'User Usage Trend (Top 12)',
model: 'Model',
group: 'Group',
noGroup: 'No Group',
requests: 'Requests',
tokens: 'Tokens',
actual: 'Actual',

View File

@@ -409,9 +409,12 @@ export default {
day: '按天',
hour: '按小时',
modelDistribution: '模型分布',
groupDistribution: '分组使用分布',
tokenUsageTrend: 'Token 使用趋势',
noDataAvailable: '暂无数据',
model: '模型',
group: '分组',
noGroup: '无分组',
requests: '请求',
tokens: 'Token',
actual: '实际',
@@ -846,9 +849,12 @@ export default {
day: '按天',
hour: '按小时',
modelDistribution: '模型分布',
groupDistribution: '分组使用分布',
tokenUsageTrend: 'Token 使用趋势',
noDataAvailable: '暂无数据',
model: '模型',
group: '分组',
noGroup: '无分组',
requests: '请求',
tokens: 'Token',
cache: '缓存',

View File

@@ -1086,6 +1086,15 @@ export interface ModelStat {
actual_cost: number // 实际扣除
}
export interface GroupStat {
group_id: number
group_name: string
requests: number
total_tokens: number
cost: number // 标准计费
actual_cost: number // 实际扣除
}
export interface UserUsageTrendPoint {
date: string
user_id: number

View File

@@ -14,8 +14,9 @@
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ModelDistributionChart :model-stats="modelStats" :loading="chartsLoading" />
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
<GroupDistributionChart :group-stats="groupStats" :loading="chartsLoading" />
</div>
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
<template #after-reset>
@@ -78,14 +79,14 @@ import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination fro
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import GroupDistributionChart from '@/components/charts/GroupDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
const { t } = useI18n()
const appStore = useAppStore()
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 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'>('day')
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
const cleanupDialogVisible = ref(false)
@@ -128,8 +129,9 @@ const loadChartData = async () => {
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, request_type: requestType, stream: legacyStream === null ? undefined : legacyStream, billing_type: filters.value.billing_type }
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, request_type: params.request_type, stream: params.stream, billing_type: params.billing_type })])
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
const statsParams = { start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, request_type: params.request_type, stream: params.stream, billing_type: params.billing_type }
const [trendRes, modelRes, groupRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats(statsParams), adminAPI.dashboard.getGroupStats(statsParams)])
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []; groupStats.value = groupRes.groups || []
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
}
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }