feat(admin/usage): 优化管理员用量页面功能和体验
后端改进: - 新增 GetStatsWithFilters 方法支持完整筛选条件 - Stats 端点支持 account_id, group_id, model, stream, billing_type 参数 - 统一使用 filters 结构体,移除冗余的分支逻辑 前端改进: - 统计卡片添加"所选范围内"文字提示 - 优化总消费显示格式,清晰展示实际费用和标准计费 - Token 和费用列添加问号图标 tooltip 显示详细信息 - API Key 搜索框体验优化:点击即显示下拉选项 - 选择用户后自动加载该用户的所有 API Key
This commit is contained in:
@@ -54,15 +54,21 @@ export async function list(
|
||||
|
||||
/**
|
||||
* Get usage statistics with optional filters (admin only)
|
||||
* @param params - Query parameters (user_id, api_key_id, period/date range)
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Usage statistics
|
||||
*/
|
||||
export async function getStats(params: {
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
billing_type?: number
|
||||
period?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
timezone?: string
|
||||
}): Promise<AdminUsageStatsResponse> {
|
||||
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
|
||||
params
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
class="input pr-8"
|
||||
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
|
||||
@input="debounceApiKeySearch"
|
||||
@focus="showApiKeyDropdown = true"
|
||||
@focus="onApiKeyFocus"
|
||||
/>
|
||||
<button
|
||||
v-if="filters.api_key_id"
|
||||
@@ -62,7 +62,7 @@
|
||||
✕
|
||||
</button>
|
||||
<div
|
||||
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
|
||||
v-if="showApiKeyDropdown && apiKeyResults.length > 0"
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
||||
>
|
||||
<button
|
||||
@@ -223,14 +223,10 @@ const debounceUserSearch = () => {
|
||||
const debounceApiKeySearch = () => {
|
||||
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
|
||||
apiKeySearchTimeout = setTimeout(async () => {
|
||||
if (!apiKeyKeyword.value) {
|
||||
apiKeyResults.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
|
||||
filters.value.user_id,
|
||||
apiKeyKeyword.value
|
||||
apiKeyKeyword.value || ''
|
||||
)
|
||||
} catch {
|
||||
apiKeyResults.value = []
|
||||
@@ -238,11 +234,19 @@ const debounceApiKeySearch = () => {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const selectUser = (u: SimpleUser) => {
|
||||
const selectUser = async (u: SimpleUser) => {
|
||||
userKeyword.value = u.email
|
||||
showUserDropdown.value = false
|
||||
filters.value.user_id = u.id
|
||||
clearApiKey()
|
||||
|
||||
// Auto-load API keys for this user
|
||||
try {
|
||||
apiKeyResults.value = await adminAPI.usage.searchApiKeys(u.id, '')
|
||||
} catch {
|
||||
apiKeyResults.value = []
|
||||
}
|
||||
|
||||
emitChange()
|
||||
}
|
||||
|
||||
@@ -274,6 +278,14 @@ const onClearApiKey = () => {
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const onApiKeyFocus = () => {
|
||||
showApiKeyDropdown.value = true
|
||||
// Trigger search if no results yet
|
||||
if (apiKeyResults.value.length === 0) {
|
||||
debounceApiKeySearch()
|
||||
}
|
||||
}
|
||||
|
||||
const onDocumentClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null
|
||||
if (!target) return
|
||||
|
||||
@@ -4,17 +4,34 @@
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
|
||||
<Icon name="document" size="md" />
|
||||
</div>
|
||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p>
|
||||
<p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p>
|
||||
<p class="text-xs text-gray-400">{{ t('usage.inSelectedRange') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center gap-3">
|
||||
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
|
||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p>
|
||||
<p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ t('usage.in') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} /
|
||||
{{ t('usage.out') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
|
||||
<Icon name="dollar" size="md" />
|
||||
</div>
|
||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
|
||||
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center gap-3">
|
||||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
|
||||
|
||||
@@ -44,32 +44,56 @@
|
||||
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
|
||||
</div>
|
||||
<!-- Token 请求 -->
|
||||
<div v-else class="space-y-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
|
||||
<div v-else class="flex items-center gap-1.5">
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
|
||||
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
|
||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
|
||||
</div>
|
||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
|
||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
|
||||
</div>
|
||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
||||
<!-- Token Detail Tooltip -->
|
||||
<div
|
||||
class="group relative"
|
||||
@mouseenter="showTokenTooltip($event, row)"
|
||||
@mouseleave="hideTokenTooltip"
|
||||
>
|
||||
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
|
||||
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-cost="{ row }">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
<div class="flex items-center gap-1.5 text-sm">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
<!-- Cost Detail Tooltip -->
|
||||
<div
|
||||
class="group relative"
|
||||
@mouseenter="showTooltip($event, row)"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
|
||||
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-billing_type="{ row }">
|
||||
@@ -106,6 +130,77 @@
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Tooltip Portal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="tokenTooltipVisible"
|
||||
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||
:style="{
|
||||
left: tokenTooltipPosition.x + 'px',
|
||||
top: tokenTooltipPosition.y + 'px'
|
||||
}"
|
||||
>
|
||||
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
|
||||
<div class="space-y-1.5">
|
||||
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||
<div class="text-xs font-semibold text-gray-300 mb-1">Token {{ t('usage.details') }}</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Cost Tooltip Portal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="tooltipVisible"
|
||||
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||
:style="{
|
||||
left: tooltipPosition.x + 'px',
|
||||
top: tooltipPosition.y + 'px'
|
||||
}"
|
||||
>
|
||||
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||||
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -116,12 +211,23 @@ import { useAppStore } from '@/stores/app'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
|
||||
defineProps(['data', 'loading'])
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const copiedRequestId = ref<string | null>(null)
|
||||
|
||||
// Tooltip state - cost
|
||||
const tooltipVisible = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tooltipData = ref<UsageLog | null>(null)
|
||||
|
||||
// Tooltip state - token
|
||||
const tokenTooltipVisible = ref(false)
|
||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||
|
||||
const cols = computed(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||
@@ -160,4 +266,34 @@ const copyRequestId = async (requestId: string) => {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Cost tooltip functions
|
||||
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
tooltipData.value = row
|
||||
tooltipPosition.value.x = rect.right + 8
|
||||
tooltipPosition.value.y = rect.top + rect.height / 2
|
||||
tooltipVisible.value = true
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
tooltipVisible.value = false
|
||||
tooltipData.value = null
|
||||
}
|
||||
|
||||
// Token tooltip functions
|
||||
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
tokenTooltipData.value = row
|
||||
tokenTooltipPosition.value.x = rect.right + 8
|
||||
tokenTooltipPosition.value.y = rect.top + rect.height / 2
|
||||
tokenTooltipVisible.value = true
|
||||
}
|
||||
|
||||
const hideTokenTooltip = () => {
|
||||
tokenTooltipVisible.value = false
|
||||
tokenTooltipData.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user