Merge pull request #509 from beilo/feat/key-ranking-usage

Add paginated API key usage leaderboard
This commit is contained in:
fawney19
2026-05-20 01:06:58 +08:00
committed by GitHub
5 changed files with 259 additions and 48 deletions

View File

@@ -7,23 +7,12 @@
<SelectValue placeholder="选择时间段" /> <SelectValue placeholder="选择时间段" />
</SelectTrigger> </SelectTrigger>
<SelectContent :searchable="false"> <SelectContent :searchable="false">
<SelectItem value="today"> <SelectItem
今天 v-for="preset in activePresetOptions"
</SelectItem> :key="preset"
<SelectItem value="yesterday"> :value="preset"
昨天 >
</SelectItem> {{ presetLabels[preset] }}
<SelectItem value="last7days">
最近7天
</SelectItem>
<SelectItem value="last30days">
最近30天
</SelectItem>
<SelectItem value="last90days">
最近90天
</SelectItem>
<SelectItem value="custom">
自定义
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -85,27 +74,51 @@ import {
} from '@/components/ui' } from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types' import type { DateRangeParams } from '@/features/usage/types'
const props = defineProps<{ const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const
type SelectablePreset = typeof selectablePresets[number]
const presetLabels: Record<SelectablePreset, string> = {
today: '今天',
yesterday: '昨天',
last7days: '最近7天',
last30days: '最近30天',
last90days: '最近90天',
custom: '自定义'
}
const props = withDefaults(defineProps<{
modelValue: DateRangeParams modelValue: DateRangeParams
showGranularity?: boolean showGranularity?: boolean
allowHourly?: boolean allowHourly?: boolean
}>() presetOptions?: SelectablePreset[]
}>(), {
presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom']
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: DateRangeParams] 'update:modelValue': [value: DateRangeParams]
}>() }>()
const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const const activePresetOptions = computed<SelectablePreset[]>(() => {
type SelectablePreset = typeof selectablePresets[number] const unique = new Set(props.presetOptions)
const filtered = selectablePresets.filter((preset) => unique.has(preset))
return filtered.length > 0 ? filtered : [...selectablePresets]
})
function defaultPreset(): SelectablePreset {
const options = activePresetOptions.value
if (options.includes('last7days')) return 'last7days'
return options[0] ?? 'last7days'
}
function normalizePreset(value: DateRangeParams): SelectablePreset { function normalizePreset(value: DateRangeParams): SelectablePreset {
if (value.preset && selectablePresets.includes(value.preset as SelectablePreset)) { if (value.preset && activePresetOptions.value.includes(value.preset as SelectablePreset)) {
return value.preset as SelectablePreset return value.preset as SelectablePreset
} }
if (!value.preset && (value.start_date || value.end_date)) { if (!value.preset && (value.start_date || value.end_date) && activePresetOptions.value.includes('custom')) {
return 'custom' return 'custom'
} }
return 'last7days' return defaultPreset()
} }
const selectedPreset = ref<SelectablePreset>(normalizePreset(props.modelValue)) const selectedPreset = ref<SelectablePreset>(normalizePreset(props.modelValue))
@@ -168,6 +181,12 @@ watch(() => props.modelValue, (value) => {
lastEmittedValue = getValueKey(value) lastEmittedValue = getValueKey(value)
}, { deep: true }) }, { deep: true })
watch(activePresetOptions, () => {
if (!activePresetOptions.value.includes(selectedPreset.value)) {
selectedPreset.value = normalizePreset(props.modelValue)
}
})
watch([selectedPreset, startDate, endDate, selectedGranularity], () => { watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
if (!allowHourly.value || !canUseHourly.value) { if (!allowHourly.value || !canUseHourly.value) {
if (selectedGranularity.value === 'hour') { if (selectedGranularity.value === 'hour') {

View File

@@ -0,0 +1,70 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<TimeRangePicker
:model-value="timeRange"
:show-granularity="false"
:preset-options="timeRangePresetOptions"
@update:model-value="$emit('update:timeRange', $event)"
/>
<Select
:model-value="metric"
@update:model-value="emitMetric"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { TimeRangePicker } from '@/components/common'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types'
type LeaderboardMetric = 'requests' | 'tokens' | 'cost'
type LeaderboardTimeRangePreset = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last90days' | 'custom'
defineProps<{
metric: LeaderboardMetric
timeRange: DateRangeParams
}>()
const emit = defineEmits<{
(e: 'update:metric', value: LeaderboardMetric): void
(e: 'update:timeRange', value: DateRangeParams): void
}>()
const timeRangePresetOptions: LeaderboardTimeRangePreset[] = [
'today',
'yesterday',
'last7days',
'last30days',
'last90days',
'custom'
]
function emitMetric(value: string) {
if (value === 'requests' || value === 'tokens' || value === 'cost') {
emit('update:metric', value)
}
}
</script>

View File

@@ -1,26 +1,28 @@
<template> <template>
<TableCard :title="title"> <TableCard :title="title">
<template #actions> <template #actions>
<Select <slot name="actions">
v-if="showMetricSelect" <Select
:model-value="metric" v-if="showMetricSelect"
@update:model-value="emitMetric" :model-value="metric"
> @update:model-value="emitMetric"
<SelectTrigger class="h-8 text-xs w-28"> >
<SelectValue placeholder="指标" /> <SelectTrigger class="h-8 text-xs w-28">
</SelectTrigger> <SelectValue placeholder="指标" />
<SelectContent> </SelectTrigger>
<SelectItem value="requests"> <SelectContent>
请求数 <SelectItem value="requests">
</SelectItem> 请求数
<SelectItem value="tokens"> </SelectItem>
Tokens <SelectItem value="tokens">
</SelectItem> Tokens
<SelectItem value="cost"> </SelectItem>
成本 <SelectItem value="cost">
</SelectItem> 成本
</SelectContent> </SelectItem>
</Select> </SelectContent>
</Select>
</slot>
</template> </template>
<div <div
@@ -77,6 +79,8 @@
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
<slot name="pagination" />
</TableCard> </TableCard>
</template> </template>

View File

@@ -1,4 +1,5 @@
export { default as ActivityHeatmap } from './ActivityHeatmap.vue' export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
export { default as LeaderboardControls } from './LeaderboardControls.vue'
export { default as LeaderboardTable } from './LeaderboardTable.vue' export { default as LeaderboardTable } from './LeaderboardTable.vue'
export { default as CostForecastChart } from './CostForecastChart.vue' export { default as CostForecastChart } from './CostForecastChart.vue'
export { default as QuotaProgressCard } from './QuotaProgressCard.vue' export { default as QuotaProgressCard } from './QuotaProgressCard.vue'

View File

@@ -64,6 +64,35 @@
/> />
</div> </div>
<LeaderboardTable
title="API Key 用量排行"
:items="apiKeyLeaderboard"
:metric="apiKeyLeaderboardMetric"
:loading="apiKeyLeaderboardLoading"
:show-metric-select="false"
@update:metric="apiKeyLeaderboardMetric = $event"
>
<template #actions>
<LeaderboardControls
:metric="apiKeyLeaderboardMetric"
:time-range="apiKeyLeaderboardTimeRange"
@update:metric="apiKeyLeaderboardMetric = $event"
@update:time-range="apiKeyLeaderboardTimeRange = $event"
/>
</template>
<template #pagination>
<Pagination
v-if="apiKeyLeaderboardTotal > 0"
:current="apiKeyLeaderboardPage"
:total="apiKeyLeaderboardTotal"
:page-size="apiKeyLeaderboardPageSize"
:page-size-options="apiKeyLeaderboardPageSizeOptions"
@update:current="apiKeyLeaderboardPage = $event"
@update:page-size="apiKeyLeaderboardPageSize = $event"
/>
</template>
</LeaderboardTable>
<UsageProviderTable <UsageProviderTable
:data="providerStats" :data="providerStats"
:is-admin="true" :is-admin="true"
@@ -74,10 +103,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import { Pagination } from '@/components/ui'
import { TimeRangePicker } from '@/components/common' import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, QuotaProgressCard } from '@/components/stats' import { CostForecastChart, LeaderboardControls, LeaderboardTable, QuotaProgressCard } from '@/components/stats'
import { UsageProviderTable } from '@/features/usage/components' import { UsageProviderTable } from '@/features/usage/components'
import { adminApi, type CostForecastResponse, type CostSavingsResponse, type QuotaUsageProvider } from '@/api/admin' import { adminApi, type CostForecastResponse, type CostSavingsResponse, type LeaderboardItem, type QuotaUsageProvider } from '@/api/admin'
import { usageApi } from '@/api/usage' import { usageApi } from '@/api/usage'
import { formatCurrency, formatTokens } from '@/utils/format' import { formatCurrency, formatTokens } from '@/utils/format'
import { getDateRangeFromPeriod } from '@/features/usage/composables' import { getDateRangeFromPeriod } from '@/features/usage/composables'
@@ -90,16 +120,26 @@ const forecast = ref<CostForecastResponse | null>(null)
const costSavings = ref<CostSavingsResponse | null>(null) const costSavings = ref<CostSavingsResponse | null>(null)
const quotaProviders = ref<QuotaUsageProvider[]>([]) const quotaProviders = ref<QuotaUsageProvider[]>([])
const providerStats = ref<ProviderStatsItem[]>([]) const providerStats = ref<ProviderStatsItem[]>([])
const apiKeyLeaderboard = ref<LeaderboardItem[]>([])
const apiKeyLeaderboardMetric = ref<'requests' | 'tokens' | 'cost'>('cost')
const apiKeyLeaderboardTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last30days'))
const apiKeyLeaderboardPage = ref(1)
const apiKeyLeaderboardPageSize = ref(10)
const apiKeyLeaderboardTotal = ref(0)
const apiKeyLeaderboardPageSizeOptions = [10, 20, 50, 100]
const forecastLoading = ref(false) const forecastLoading = ref(false)
const quotaLoading = ref(false) const quotaLoading = ref(false)
const apiKeyLeaderboardLoading = ref(false)
let forecastRequestId = 0 let forecastRequestId = 0
let savingsRequestId = 0 let savingsRequestId = 0
let quotaRequestId = 0 let quotaRequestId = 0
let providerStatsRequestId = 0 let providerStatsRequestId = 0
let apiKeyLeaderboardRequestId = 0
let loadAllPromise: Promise<void> | null = null let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false let hasPendingLoadAll = false
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
let apiKeyLeaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
const forecastHistory = computed(() => forecast.value?.history || []) const forecastHistory = computed(() => forecast.value?.history || [])
const forecastFuture = computed(() => forecast.value?.forecast || []) const forecastFuture = computed(() => forecast.value?.forecast || [])
@@ -159,12 +199,55 @@ async function loadProviderStats() {
providerStats.value = stats providerStats.value = stats
} }
async function loadApiKeyLeaderboard() {
const requestId = ++apiKeyLeaderboardRequestId
apiKeyLeaderboardLoading.value = true
try {
const response = await adminApi.getLeaderboardApiKeys({
...buildApiKeyLeaderboardTimeRangeParams(),
metric: apiKeyLeaderboardMetric.value,
order: 'desc',
limit: apiKeyLeaderboardPageSize.value,
offset: (apiKeyLeaderboardPage.value - 1) * apiKeyLeaderboardPageSize.value,
include_inactive: false,
exclude_admin: false
})
if (requestId !== apiKeyLeaderboardRequestId) return
apiKeyLeaderboard.value = response.items
apiKeyLeaderboardTotal.value = response.total
if (response.items.length === 0 && response.total > 0 && apiKeyLeaderboardPage.value > 1) {
apiKeyLeaderboardPage.value = 1
scheduleApiKeyLeaderboardLoad()
}
} finally {
if (requestId === apiKeyLeaderboardRequestId) {
apiKeyLeaderboardLoading.value = false
}
}
}
function buildApiKeyLeaderboardTimeRangeParams() {
return {
start_date: apiKeyLeaderboardTimeRange.value.start_date,
end_date: apiKeyLeaderboardTimeRange.value.end_date,
preset: apiKeyLeaderboardTimeRange.value.preset,
timezone: apiKeyLeaderboardTimeRange.value.timezone,
tz_offset_minutes: apiKeyLeaderboardTimeRange.value.tz_offset_minutes
}
}
async function loadAll() { async function loadAll() {
if (loadAllPromise) { if (loadAllPromise) {
hasPendingLoadAll = true hasPendingLoadAll = true
return loadAllPromise return loadAllPromise
} }
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()]) loadAllPromise = Promise.all([
loadForecast(),
loadSavings(),
loadQuotaUsage(),
loadProviderStats(),
loadApiKeyLeaderboard()
])
.then(() => undefined) .then(() => undefined)
.finally(() => { .finally(() => {
loadAllPromise = null loadAllPromise = null
@@ -186,7 +269,36 @@ function scheduleLoadAll() {
}, 120) }, 120)
} }
watch(timeRange, scheduleLoadAll, { deep: true }) function scheduleApiKeyLeaderboardLoad() {
if (apiKeyLeaderboardDebounceTimer) {
clearTimeout(apiKeyLeaderboardDebounceTimer)
}
apiKeyLeaderboardDebounceTimer = setTimeout(() => {
apiKeyLeaderboardDebounceTimer = null
void loadApiKeyLeaderboard()
}, 120)
}
function resetApiKeyLeaderboardPage() {
if (apiKeyLeaderboardPage.value === 1) {
return
}
apiKeyLeaderboardPage.value = 1
}
watch(timeRange, () => {
resetApiKeyLeaderboardPage()
scheduleLoadAll()
}, { deep: true })
watch(apiKeyLeaderboardMetric, () => {
resetApiKeyLeaderboardPage()
scheduleApiKeyLeaderboardLoad()
})
watch(apiKeyLeaderboardTimeRange, () => {
resetApiKeyLeaderboardPage()
scheduleApiKeyLeaderboardLoad()
}, { deep: true })
watch([apiKeyLeaderboardPage, apiKeyLeaderboardPageSize], scheduleApiKeyLeaderboardLoad)
onMounted(() => { onMounted(() => {
void loadAll() void loadAll()
@@ -197,11 +309,16 @@ onUnmounted(() => {
clearTimeout(loadAllDebounceTimer) clearTimeout(loadAllDebounceTimer)
loadAllDebounceTimer = null loadAllDebounceTimer = null
} }
if (apiKeyLeaderboardDebounceTimer) {
clearTimeout(apiKeyLeaderboardDebounceTimer)
apiKeyLeaderboardDebounceTimer = null
}
hasPendingLoadAll = false hasPendingLoadAll = false
loadAllPromise = null loadAllPromise = null
forecastRequestId += 1 forecastRequestId += 1
savingsRequestId += 1 savingsRequestId += 1
quotaRequestId += 1 quotaRequestId += 1
providerStatsRequestId += 1 providerStatsRequestId += 1
apiKeyLeaderboardRequestId += 1
}) })
</script> </script>