feat(stats): add user group usage views

This commit is contained in:
wangpengxiang
2026-09-18 16:24:03 +08:00
parent fb25dde4c9
commit a95f0d2488
24 changed files with 1198 additions and 346 deletions
@@ -82,4 +82,35 @@ describe('adminApi analytics cache options', () => {
})
expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params })
})
it('requests the user group leaderboard with scoped cache parameters', async () => {
const groupParams = {
...params,
metric: 'cost' as const,
offset: 10,
limit: 10,
include_inactive: true,
}
getMock.mockResolvedValueOnce({
data: { items: [], total: 0, metric: 'cost', attribution: 'current_membership' },
})
await expect(adminApi.getLeaderboardUserGroups(groupParams)).resolves.toMatchObject({
attribution: 'current_membership',
})
expect(buildCacheKeyMock).toHaveBeenCalledWith(
'admin:stats:leaderboard:user-groups',
groupParams
)
expect(cachedRequestMock).toHaveBeenCalledWith(
'admin:stats:leaderboard:user-groups',
expect.any(Function),
20 * 1000
)
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', {
params: groupParams,
})
})
})
+34
View File
@@ -709,6 +709,8 @@ export interface LeaderboardItem {
requests: number
tokens: number
cost: number
member_count?: number
active_member_count?: number
}
export interface LeaderboardResponse {
@@ -717,6 +719,7 @@ export interface LeaderboardResponse {
metric: string
start_date?: string | null
end_date?: string | null
attribution?: 'current_membership'
}
export interface CostForecastResponse {
@@ -1298,6 +1301,7 @@ export const adminApi = {
model?: string
include_inactive?: boolean
exclude_admin?: boolean
user_group_id?: string
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
return cachedRequest(
@@ -1312,6 +1316,35 @@ export const adminApi = {
)
},
async getLeaderboardUserGroups(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>(
'/api/admin/stats/leaderboard/user-groups',
{ params }
)
return response.data
},
20 * 1000
)
},
async getLeaderboardApiKeys(params?: {
start_date?: string
end_date?: string
@@ -1595,6 +1628,7 @@ export const adminApi = {
timezone?: string
tz_offset_minutes?: number
user_id?: string
user_group_id?: string
model?: string
provider_name?: string
},
+1
View File
@@ -134,6 +134,7 @@ export interface UsageByApiFormat {
export interface UsageFilters {
user_id?: string // UUID
user_group_id?: string // UUID
provider_id?: string // UUID
model?: string
search?: string
@@ -8,17 +8,17 @@
@update:model-value="emitMetric"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
<SelectValue :placeholder="t('stats.metric.placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
{{ t('stats.metric.requests') }}
</SelectItem>
<SelectItem value="tokens">
Tokens
{{ t('stats.metric.tokens') }}
</SelectItem>
<SelectItem value="cost">
成本
{{ t('stats.metric.cost') }}
</SelectItem>
</SelectContent>
</Select>
@@ -36,25 +36,31 @@
class="p-6"
>
<EmptyState
title="暂无数据"
description="当前时间范围内没有统计结果"
:title="t('stats.empty.title')"
:description="t('stats.empty.description')"
/>
</div>
<Table v-else>
<TableHeader>
<TableRow>
<TableHead class="w-16">
排名
{{ t('stats.column.rank') }}
</TableHead>
<TableHead>名称</TableHead>
<TableHead class="text-right">
请求数
<TableHead>{{ t('stats.column.name') }}</TableHead>
<TableHead
v-if="showMemberCount"
class="text-right"
>
{{ t('stats.column.members') }}
</TableHead>
<TableHead class="text-right">
Tokens
{{ t('stats.metric.requests') }}
</TableHead>
<TableHead class="text-right">
成本
{{ t('stats.metric.tokens') }}
</TableHead>
<TableHead class="text-right">
{{ t('stats.metric.cost') }}
</TableHead>
</TableRow>
</TableHeader>
@@ -62,11 +68,22 @@
<TableRow
v-for="item in items"
:key="item.id"
:class="selectable ? 'cursor-pointer hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' : undefined"
:tabindex="selectable ? 0 : undefined"
@click="selectable && emit('select', item)"
@keydown.enter.prevent="selectable && emit('select', item)"
@keydown.space.prevent="selectable && emit('select', item)"
>
<TableCell class="font-medium">
{{ item.rank }}
</TableCell>
<TableCell>{{ item.name }}</TableCell>
<TableCell
v-if="showMemberCount"
class="text-right"
>
{{ item.active_member_count ?? 0 }} / {{ item.member_count ?? 0 }}
</TableCell>
<TableCell class="text-right">
{{ item.requests }}
</TableCell>
@@ -102,6 +119,7 @@ import {
TableRow
} from '@/components/ui'
import { formatCurrency, formatTokens } from '@/utils/format'
import { useI18n } from '@/i18n'
import type { LeaderboardItem } from '@/api/admin'
interface Props {
@@ -110,17 +128,24 @@ interface Props {
metric: 'requests' | 'tokens' | 'cost'
loading?: boolean
showMetricSelect?: boolean
showMemberCount?: boolean
selectable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
showMetricSelect: true
showMetricSelect: true,
showMemberCount: false,
selectable: false
})
const emit = defineEmits<{
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
(e: 'select', value: LeaderboardItem): void
}>()
const { t } = useI18n()
const metric = computed(() => props.metric)
function emitMetric(value: string) {
+76
View File
@@ -346,6 +346,44 @@ export const messages = {
'nav.usageStats': '使用统计',
'nav.operations': '运维总览',
'nav.userStats': '用户统计',
'userStats.title': '用户统计',
'userStats.description': '按用户或当前用户组成员查看排行榜与使用趋势',
'userStats.scope.placeholder': '统计维度',
'userStats.scope.user': '按用户',
'userStats.scope.userGroup': '按用户组',
'userStats.search.user': '搜索用户',
'userStats.search.userGroup': '搜索用户组',
'userStats.select.user': '选择用户',
'userStats.select.userGroup': '选择用户组',
'userStats.compare.placeholder': '对比对象(可选)',
'userStats.compare.none': '不对比',
'userStats.leaderboard.user': '用户排行榜',
'userStats.leaderboard.userGroup': '用户组排行榜',
'userStats.pagination.summary': '共 {total} 项 · 第 {page} 页',
'userStats.pagination.previous': '上一页',
'userStats.pagination.next': '下一页',
'userStats.summary.user': '用户摘要',
'userStats.summary.userGroup': '用户组摘要',
'userStats.selectPrompt': '请选择统计对象',
'userStats.members.current': '当前成员',
'userStats.members.active': '活跃成员',
'userStats.memberLeaderboard': '组内成员排行',
'userStats.trend.user': '用户使用趋势',
'userStats.trend.userGroup': '用户组使用趋势',
'userStats.comparisonTrend.user': '用户对比趋势',
'userStats.comparisonTrend.userGroup': '用户组对比趋势',
'userStats.chart.current': '当前对象',
'userStats.chart.comparison': '对比对象',
'stats.metric.placeholder': '指标',
'stats.metric.requests': '请求数',
'stats.metric.tokens': 'Tokens',
'stats.metric.cost': '成本',
'stats.metric.errorRate': '错误率',
'stats.empty.title': '暂无数据',
'stats.empty.description': '当前时间范围内没有统计结果',
'stats.column.rank': '排名',
'stats.column.name': '名称',
'stats.column.members': '成员',
'nav.costAnalysis': '成本分析',
'nav.performanceAnalysis': '性能分析',
'nav.userManagement': '用户管理',
@@ -792,6 +830,44 @@ export const messages = {
'nav.usageStats': 'Usage stats',
'nav.operations': 'Operations',
'nav.userStats': 'User stats',
'userStats.title': 'User statistics',
'userStats.description': 'View leaderboards and usage trends by user or current user-group membership',
'userStats.scope.placeholder': 'Statistics scope',
'userStats.scope.user': 'By user',
'userStats.scope.userGroup': 'By user group',
'userStats.search.user': 'Search users',
'userStats.search.userGroup': 'Search user groups',
'userStats.select.user': 'Select a user',
'userStats.select.userGroup': 'Select a user group',
'userStats.compare.placeholder': 'Comparison (optional)',
'userStats.compare.none': 'No comparison',
'userStats.leaderboard.user': 'User leaderboard',
'userStats.leaderboard.userGroup': 'User-group leaderboard',
'userStats.pagination.summary': '{total} items · Page {page}',
'userStats.pagination.previous': 'Previous',
'userStats.pagination.next': 'Next',
'userStats.summary.user': 'User summary',
'userStats.summary.userGroup': 'User-group summary',
'userStats.selectPrompt': 'Select a statistics entity',
'userStats.members.current': 'Current members',
'userStats.members.active': 'Active members',
'userStats.memberLeaderboard': 'Group member leaderboard',
'userStats.trend.user': 'User usage trend',
'userStats.trend.userGroup': 'User-group usage trend',
'userStats.comparisonTrend.user': 'User comparison trend',
'userStats.comparisonTrend.userGroup': 'User-group comparison trend',
'userStats.chart.current': 'Current entity',
'userStats.chart.comparison': 'Comparison entity',
'stats.metric.placeholder': 'Metric',
'stats.metric.requests': 'Requests',
'stats.metric.tokens': 'Tokens',
'stats.metric.cost': 'Cost',
'stats.metric.errorRate': 'Error rate',
'stats.empty.title': 'No data',
'stats.empty.description': 'No statistics are available for the current time range',
'stats.column.rank': 'Rank',
'stats.column.name': 'Name',
'stats.column.members': 'Members',
'nav.costAnalysis': 'Cost analysis',
'nav.performanceAnalysis': 'Performance analysis',
'nav.userManagement': 'Users',
+337 -193
View File
@@ -1,70 +1,117 @@
<template>
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<h1 class="text-lg font-semibold">
用户统计
{{ t('userStats.title') }}
</h1>
<p class="text-xs text-muted-foreground">
查看用户排行榜与使用趋势
{{ t('userStats.description') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="scope">
<SelectTrigger class="h-8 w-32 text-xs">
<SelectValue :placeholder="t('userStats.scope.placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">
{{ t('userStats.scope.user') }}
</SelectItem>
<SelectItem value="user_group">
{{ t('userStats.scope.userGroup') }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="selectedEntityId">
<SelectTrigger class="h-8 w-52 text-xs">
<SelectValue :placeholder="scope === 'user' ? t('userStats.select.user') : t('userStats.select.userGroup')" />
</SelectTrigger>
<SelectContent
:search-threshold="0"
:search-placeholder="scope === 'user' ? t('userStats.search.user') : t('userStats.search.userGroup')"
>
<SelectItem
v-for="entity in allEntities"
:key="entity.id"
:value="entity.id"
>
{{ entity.name }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="compareEntityId">
<SelectTrigger class="h-8 w-52 text-xs">
<SelectValue :placeholder="t('userStats.compare.placeholder')" />
</SelectTrigger>
<SelectContent
:search-threshold="0"
:search-placeholder="scope === 'user' ? t('userStats.search.user') : t('userStats.search.userGroup')"
>
<SelectItem value="__none__">
{{ t('userStats.compare.none') }}
</SelectItem>
<SelectItem
v-for="entity in comparisonEntities"
:key="`compare-${entity.id}`"
:value="entity.id"
>
{{ entity.name }}
</SelectItem>
</SelectContent>
</Select>
<TimeRangePicker
v-model="timeRange"
:allow-hourly="true"
/>
<Select
v-model="selectedUserId"
>
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="选择用户" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="compareUserId"
>
<SelectTrigger class="h-8 text-xs w-52">
<SelectValue placeholder="对比用户(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
不对比
</SelectItem>
<SelectItem
v-for="user in users"
:key="`compare-${user.id}`"
:value="user.id"
>
{{ user.username || user.email }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<LeaderboardTable
title="用户排行榜"
:title="scope === 'user' ? t('userStats.leaderboard.user') : t('userStats.leaderboard.userGroup')"
:items="leaderboard"
:metric="metric"
:loading="leaderboardLoading"
:show-member-count="scope === 'user_group'"
selectable
@update:metric="metric = $event"
/>
@select="selectLeaderboardItem"
>
<template #pagination>
<div class="flex items-center justify-between border-t px-4 py-3 text-xs text-muted-foreground">
<span>{{ t('userStats.pagination.summary', { total: leaderboardTotal, page: currentPage }) }}</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
:disabled="leaderboardOffset === 0 || leaderboardLoading"
@click="changeLeaderboardPage(-1)"
>
{{ t('userStats.pagination.previous') }}
</Button>
<Button
variant="outline"
size="sm"
:disabled="!hasNextLeaderboardPage || leaderboardLoading"
@click="changeLeaderboardPage(1)"
>
{{ t('userStats.pagination.next') }}
</Button>
</div>
</div>
</template>
</LeaderboardTable>
<Card class="p-4 space-y-3">
<h3 class="text-sm font-semibold">
用户摘要
</h3>
<Card class="space-y-3 p-4">
<div>
<h3 class="text-sm font-semibold">
{{ scope === 'user' ? t('userStats.summary.user') : t('userStats.summary.userGroup') }}
</h3>
<p class="mt-0.5 truncate text-xs text-muted-foreground">
{{ selectedEntityName || t('userStats.selectPrompt') }}
</p>
</div>
<div
v-if="summaryLoading"
class="p-6"
@@ -77,44 +124,78 @@
>
<div>
<div class="text-xs text-muted-foreground">
请求数
{{ t('stats.metric.requests') }}
</div>
<div class="font-semibold">
{{ userSummary?.total_requests ?? 0 }}
{{ usageSummary?.total_requests ?? 0 }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
Tokens
{{ t('stats.metric.tokens') }}
</div>
<div class="font-semibold">
{{ formatTokens(userSummary?.total_tokens ?? 0) }}
{{ formatTokens(usageSummary?.total_tokens ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
成本
{{ t('stats.metric.cost') }}
</div>
<div class="font-semibold">
{{ formatCurrency(userSummary?.total_cost ?? 0) }}
{{ formatCurrency(usageSummary?.total_cost ?? 0) }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
错误率
{{ t('stats.metric.errorRate') }}
</div>
<div class="font-semibold">
{{ userSummary?.error_rate ?? 0 }}%
{{ usageSummary?.error_rate ?? 0 }}%
</div>
</div>
<template v-if="scope === 'user_group'">
<div>
<div class="text-xs text-muted-foreground">
{{ t('userStats.members.current') }}
</div>
<div class="font-semibold">
{{ groupMemberCount }}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">
{{ t('userStats.members.active') }}
</div>
<div class="font-semibold">
{{ activeGroupMemberCount }}
</div>
</div>
</template>
</div>
</Card>
</div>
<Card class="p-4 space-y-4">
<h3 class="text-sm font-semibold">
用户使用趋势
</h3>
<LeaderboardTable
v-if="scope === 'user_group'"
:title="t('userStats.memberLeaderboard')"
:items="memberLeaderboard"
:metric="metric"
:loading="memberLeaderboardLoading"
:show-metric-select="false"
selectable
@select="selectMember"
/>
<Card class="space-y-4 p-4">
<div>
<h3 class="text-sm font-semibold">
{{ scope === 'user' ? t('userStats.trend.user') : t('userStats.trend.userGroup') }}
</h3>
<p class="mt-0.5 truncate text-xs text-muted-foreground">
{{ selectedEntityName || t('userStats.selectPrompt') }}
</p>
</div>
<div
v-if="seriesLoading"
class="p-6"
@@ -131,10 +212,10 @@
<Card
v-if="comparisonSeries.length > 0"
class="p-4 space-y-4"
class="space-y-4 p-4"
>
<h3 class="text-sm font-semibold">
用户对比趋势
{{ scope === 'user' ? t('userStats.comparisonTrend.user') : t('userStats.comparisonTrend.userGroup') }}
</h3>
<div class="h-[280px]">
<LineChart :data="comparisonChartData" />
@@ -144,27 +225,29 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
Button,
Card,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common'
import { LeaderboardTable } from '@/components/stats'
import { adminApi, type LeaderboardItem } from '@/api/admin'
import { usersApi, type User } from '@/api/users'
import { usersApi, type User, type UserGroup, type UserGroupMember } from '@/api/users'
import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format'
import { useI18n } from '@/i18n'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import type { DateRangeParams } from '@/features/usage/types'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const users = ref<User[]>([])
const selectedUserId = ref<string | null>(null)
const compareUserId = ref<string>('__none__')
const leaderboard = ref<LeaderboardItem[]>([])
const leaderboardLoading = ref(false)
type StatsScope = 'user' | 'user_group'
type SelectableEntity = { id: string; name: string }
interface UsageSummary {
total_requests: number
@@ -178,21 +261,73 @@ interface TimeSeriesItem {
total_cost: number
}
const userSummary = ref<UsageSummary | null>(null)
const summaryLoading = ref(false)
const { t } = useI18n()
const PAGE_SIZE = 10
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const metric = ref<'requests' | 'tokens' | 'cost'>('requests')
const scope = ref<StatsScope>('user')
const users = ref<User[]>([])
const userGroups = ref<UserGroup[]>([])
const selectedUserId = ref('')
const selectedUserGroupId = ref('')
const compareUserId = ref('__none__')
const compareUserGroupId = ref('__none__')
const leaderboard = ref<LeaderboardItem[]>([])
const leaderboardTotal = ref(0)
const leaderboardOffset = ref(0)
const leaderboardLoading = ref(false)
const memberLeaderboard = ref<LeaderboardItem[]>([])
const memberLeaderboardLoading = ref(false)
const groupMemberCount = ref(0)
const activeGroupMemberCount = ref(0)
const usageSummary = ref<UsageSummary | null>(null)
const summaryLoading = ref(false)
const series = ref<TimeSeriesItem[]>([])
const comparisonSeries = ref<TimeSeriesItem[]>([])
const seriesLoading = ref(false)
let leaderboardRequestId = 0
let summaryRequestId = 0
let seriesRequestId = 0
let leaderboardLoadPromise: Promise<void> | null = null
let hasPendingLeaderboardLoad = false
let panelRequestId = 0
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
let userPanelsLoadPromise: Promise<void> | null = null
let hasPendingUserPanelsLoad = false
let userPanelsDebounceTimer: ReturnType<typeof setTimeout> | null = null
let panelDebounceTimer: ReturnType<typeof setTimeout> | null = null
let ready = false
const allEntities = computed<SelectableEntity[]>(() => scope.value === 'user'
? users.value.map(user => ({ id: user.id, name: user.username || user.email || user.id }))
: userGroups.value.map(group => ({ id: group.id, name: group.name })))
const selectedEntityId = computed({
get: () => scope.value === 'user' ? selectedUserId.value : selectedUserGroupId.value,
set: (value: string) => {
if (scope.value === 'user') selectedUserId.value = value
else selectedUserGroupId.value = value
}
})
const compareEntityId = computed({
get: () => scope.value === 'user' ? compareUserId.value : compareUserGroupId.value,
set: (value: string) => {
if (scope.value === 'user') compareUserId.value = value
else compareUserGroupId.value = value
}
})
const comparisonEntities = computed(() => allEntities.value.filter(
entity => entity.id !== selectedEntityId.value
))
const selectedEntityName = computed(() => allEntities.value.find(
entity => entity.id === selectedEntityId.value
)?.name ?? '')
const comparedEntityName = computed(() => allEntities.value.find(
entity => entity.id === compareEntityId.value
)?.name ?? '')
const currentPage = computed(() => Math.floor(leaderboardOffset.value / PAGE_SIZE) + 1)
const hasNextLeaderboardPage = computed(
() => leaderboardOffset.value + leaderboard.value.length < leaderboardTotal.value
)
function buildTimeRangeParams() {
return {
@@ -205,136 +340,149 @@ function buildTimeRangeParams() {
}
}
async function loadUsers() {
users.value = await usersApi.getAllUsers()
if (!selectedUserId.value && users.value.length > 0) {
selectedUserId.value = users.value[0].id
function scopeParams(id: string) {
return scope.value === 'user' ? { user_id: id } : { user_group_id: id }
}
function ensureSelectedEntity() {
const entities = allEntities.value
if (!entities.some(entity => entity.id === selectedEntityId.value)) {
selectedEntityId.value = entities[0]?.id ?? ''
}
if (compareEntityId.value !== '__none__' && !entities.some(entity => entity.id === compareEntityId.value)) {
compareEntityId.value = '__none__'
}
}
async function loadEntities() {
const [loadedUsers, groupsResponse] = await Promise.all([
usersApi.getAllUsers(),
usersApi.listUserGroups()
])
users.value = loadedUsers
userGroups.value = groupsResponse.items
ensureSelectedEntity()
}
async function loadLeaderboard() {
if (leaderboardLoadPromise) {
hasPendingLeaderboardLoad = true
return leaderboardLoadPromise
}
leaderboardLoadPromise = (async () => {
const requestId = ++leaderboardRequestId
leaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardUsers({
const params = {
...buildTimeRangeParams(),
metric: metric.value,
limit: 10
})
limit: PAGE_SIZE,
offset: leaderboardOffset.value
}
const response = scope.value === 'user'
? await adminApi.getLeaderboardUsers(params)
: await adminApi.getLeaderboardUserGroups(params)
if (requestId !== leaderboardRequestId) return
leaderboard.value = response.items
leaderboardTotal.value = response.total
} finally {
if (requestId === leaderboardRequestId) {
leaderboardLoading.value = false
}
if (requestId === leaderboardRequestId) leaderboardLoading.value = false
}
})().finally(() => {
leaderboardLoadPromise = null
if (hasPendingLeaderboardLoad) {
hasPendingLeaderboardLoad = false
void loadLeaderboard()
}
})
return leaderboardLoadPromise
}
async function loadSummary() {
if (!selectedUserId.value) return
const requestId = ++summaryRequestId
async function loadPanels() {
const selectedId = selectedEntityId.value
const requestId = ++panelRequestId
if (!selectedId) {
usageSummary.value = null
series.value = []
comparisonSeries.value = []
memberLeaderboard.value = []
groupMemberCount.value = 0
activeGroupMemberCount.value = 0
return
}
summaryLoading.value = true
try {
const summary = await usageApi.getUsageStats({
...buildTimeRangeParams(),
user_id: selectedUserId.value
})
if (requestId !== summaryRequestId) return
userSummary.value = { ...summary, error_rate: summary.error_rate ?? 0 }
} finally {
if (requestId === summaryRequestId) {
summaryLoading.value = false
}
}
}
async function loadSeries() {
if (!selectedUserId.value) return
const requestId = ++seriesRequestId
seriesLoading.value = true
memberLeaderboardLoading.value = scope.value === 'user_group'
try {
const baseParams = {
...buildTimeRangeParams(),
user_id: selectedUserId.value
}
const shouldCompare = Boolean(compareUserId.value && compareUserId.value !== '__none__')
const comparePromise: Promise<TimeSeriesItem[]> = shouldCompare
const primaryParams = { ...buildTimeRangeParams(), ...scopeParams(selectedId) }
const shouldCompare = compareEntityId.value !== '__none__'
const comparisonPromise: Promise<TimeSeriesItem[]> = shouldCompare
? adminApi.getTimeSeries({
...buildTimeRangeParams(),
user_id: compareUserId.value
...scopeParams(compareEntityId.value)
})
: Promise.resolve([])
const memberPromise: Promise<{ items: LeaderboardItem[] }> = scope.value === 'user_group'
? adminApi.getLeaderboardUsers({
...buildTimeRangeParams(),
metric: metric.value,
user_group_id: selectedId,
limit: PAGE_SIZE
})
: Promise.resolve({ items: [] })
const groupMembersPromise: Promise<UserGroupMember[]> = scope.value === 'user_group'
? usersApi.listUserGroupMembers(selectedId)
: Promise.resolve([])
const [primarySeries, compareSeries] = await Promise.all([
adminApi.getTimeSeries(baseParams),
comparePromise
const [summary, primarySeries, compareSeries, members, groupMembers] = await Promise.all([
usageApi.getUsageStats(primaryParams),
adminApi.getTimeSeries(primaryParams),
comparisonPromise,
memberPromise,
groupMembersPromise
])
if (requestId !== seriesRequestId) return
if (requestId !== panelRequestId) return
usageSummary.value = { ...summary, error_rate: summary.error_rate ?? 0 }
series.value = primarySeries
comparisonSeries.value = compareSeries
memberLeaderboard.value = members.items
groupMemberCount.value = groupMembers.filter(member => !member.is_deleted).length
activeGroupMemberCount.value = groupMembers.filter(
member => !member.is_deleted && member.is_active
).length
} finally {
if (requestId === seriesRequestId) {
if (requestId === panelRequestId) {
summaryLoading.value = false
seriesLoading.value = false
memberLeaderboardLoading.value = false
}
}
}
async function loadUserPanels() {
if (userPanelsLoadPromise) {
hasPendingUserPanelsLoad = true
return userPanelsLoadPromise
}
userPanelsLoadPromise = Promise.all([loadSummary(), loadSeries()])
.then(() => undefined)
.finally(() => {
userPanelsLoadPromise = null
if (hasPendingUserPanelsLoad) {
hasPendingUserPanelsLoad = false
void loadUserPanels()
}
})
return userPanelsLoadPromise
function selectLeaderboardItem(item: LeaderboardItem) {
selectedEntityId.value = item.id
}
function selectMember(item: LeaderboardItem) {
scope.value = 'user'
selectedUserId.value = item.id
}
function changeLeaderboardPage(direction: -1 | 1) {
leaderboardOffset.value = Math.max(0, leaderboardOffset.value + direction * PAGE_SIZE)
void loadLeaderboard()
}
const seriesChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '成本',
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
}
]
datasets: [{
label: t('stats.metric.cost'),
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
}]
}))
const comparisonChartData = computed(() => ({
labels: series.value.map(item => item.date),
datasets: [
{
label: '当前用户',
label: selectedEntityName.value || t('userStats.chart.current'),
data: series.value.map(item => item.total_cost),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: '对比用户',
label: comparedEntityName.value || t('userStats.chart.comparison'),
data: comparisonSeries.value.map(item => item.total_cost),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
@@ -344,50 +492,46 @@ const comparisonChartData = computed(() => ({
}))
function scheduleLeaderboardLoad() {
if (leaderboardDebounceTimer) {
clearTimeout(leaderboardDebounceTimer)
}
if (!ready) return
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
leaderboardDebounceTimer = setTimeout(() => {
leaderboardDebounceTimer = null
void loadLeaderboard()
}, 120)
}
function scheduleUserPanelsLoad() {
if (userPanelsDebounceTimer) {
clearTimeout(userPanelsDebounceTimer)
}
userPanelsDebounceTimer = setTimeout(() => {
userPanelsDebounceTimer = null
void loadUserPanels()
function schedulePanelLoad() {
if (!ready) return
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
panelDebounceTimer = setTimeout(() => {
panelDebounceTimer = null
void loadPanels()
}, 120)
}
watch([timeRange, metric], scheduleLeaderboardLoad, { deep: true })
watch([timeRange, selectedUserId, compareUserId], scheduleUserPanelsLoad, { deep: true })
watch(scope, () => {
leaderboardOffset.value = 0
ensureSelectedEntity()
scheduleLeaderboardLoad()
schedulePanelLoad()
})
watch([timeRange, metric], () => {
leaderboardOffset.value = 0
scheduleLeaderboardLoad()
schedulePanelLoad()
}, { deep: true })
watch([selectedEntityId, compareEntityId], schedulePanelLoad)
onMounted(async () => {
await Promise.all([
loadLeaderboard(),
loadUsers()
])
await loadEntities()
ready = true
await Promise.all([loadLeaderboard(), loadPanels()])
})
onUnmounted(() => {
if (leaderboardDebounceTimer) {
clearTimeout(leaderboardDebounceTimer)
leaderboardDebounceTimer = null
}
if (userPanelsDebounceTimer) {
clearTimeout(userPanelsDebounceTimer)
userPanelsDebounceTimer = null
}
hasPendingLeaderboardLoad = false
hasPendingUserPanelsLoad = false
leaderboardLoadPromise = null
userPanelsLoadPromise = null
if (leaderboardDebounceTimer) clearTimeout(leaderboardDebounceTimer)
if (panelDebounceTimer) clearTimeout(panelDebounceTimer)
leaderboardRequestId += 1
summaryRequestId += 1
seriesRequestId += 1
panelRequestId += 1
})
</script>