mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
feat(stats): add user group usage views
This commit is contained in:
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user