feat: add usage statistics and records feature with new API routes, frontend types, services, and UI components

This commit is contained in:
AoaoMH
2026-01-05 17:03:05 +08:00
parent 465da6f818
commit 93ab9b6a5e
8 changed files with 112 additions and 25 deletions

View File

@@ -262,7 +262,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -306,7 +305,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1600,7 +1598,6 @@
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
@@ -1679,7 +1676,6 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0",
@@ -2008,7 +2004,6 @@
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "4.0.10",
"fflate": "^0.8.2",
@@ -2306,7 +2301,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2608,7 +2602,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.2",
"caniuse-lite": "^1.0.30001741",
@@ -2725,7 +2718,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2948,7 +2940,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -3201,7 +3192,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4101,7 +4091,6 @@
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4",
@@ -4666,7 +4655,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4734,7 +4722,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5765,7 +5752,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5859,7 +5845,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5935,7 +5920,6 @@
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.10",
"@vitest/mocker": "4.0.10",
@@ -6020,7 +6004,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21",
@@ -6053,6 +6036,7 @@
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0",
@@ -6077,6 +6061,7 @@
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},

View File

@@ -166,6 +166,7 @@ export const usageApi = {
end_date?: string
user_id?: string // UUID
username?: string
user_api_key_name?: string
model?: string
provider?: string
status?: string // 'stream' | 'standard' | 'error'

View File

@@ -56,6 +56,20 @@
</SelectContent>
</Select>
<!-- Key 名称筛选请求使用的用户 Key仅管理员可见 -->
<div
v-if="isAdmin"
class="relative"
>
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="usage-records-key-name"
v-model="localKeyName"
placeholder="搜索密钥名称"
class="w-24 sm:w-40 h-8 text-xs border-border/60 pl-8"
/>
</div>
<!-- 模型筛选 -->
<Select
v-model:open="filterModelSelectOpen"
@@ -218,7 +232,18 @@
class="py-4 w-[100px] truncate"
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
>
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
<div class="flex flex-col text-xs gap-0.5">
<span class="truncate">
{{ record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户') }}
</span>
<span
v-if="record.api_key?.name"
class="text-muted-foreground truncate"
:title="record.api_key.name"
>
{{ record.api_key.name }}
</span>
</div>
</TableCell>
<TableCell
class="font-medium py-4 w-[140px]"
@@ -438,6 +463,7 @@ import {
TableCard,
Badge,
Button,
Input,
Select,
SelectTrigger,
SelectValue,
@@ -451,7 +477,7 @@ import {
TableCell,
Pagination,
} from '@/components/ui'
import { RefreshCcw } from 'lucide-vue-next'
import { RefreshCcw, Search } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick'
@@ -472,6 +498,7 @@ const props = defineProps<{
selectedPeriod: string
// 筛选
filterUser: string
filterKeyName: string
filterModel: string
filterProvider: string
filterStatus: string
@@ -490,6 +517,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:selectedPeriod': [value: string]
'update:filterUser': [value: string]
'update:filterKeyName': [value: string]
'update:filterModel': [value: string]
'update:filterProvider': [value: string]
'update:filterStatus': [value: string]
@@ -507,6 +535,23 @@ const filterModelSelectOpen = ref(false)
const filterProviderSelectOpen = ref(false)
const filterStatusSelectOpen = ref(false)
// Key 名称筛选(输入防抖)
const localKeyName = ref(props.filterKeyName)
let keyNameDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => props.filterKeyName, (value) => {
if (value !== localKeyName.value) {
localKeyName.value = value
}
})
watch(localKeyName, (value) => {
if (keyNameDebounceTimer) clearTimeout(keyNameDebounceTimer)
keyNameDebounceTimer = setTimeout(() => {
emit('update:filterKeyName', value)
}, 300)
})
// 动态计时器相关
const now = ref(Date.now())
let timerInterval: ReturnType<typeof setInterval> | null = null
@@ -574,6 +619,10 @@ function handleRowClick(event: MouseEvent, id: string) {
// 组件卸载时清理
onUnmounted(() => {
stopTimer()
if (keyNameDebounceTimer) {
clearTimeout(keyNameDebounceTimer)
keyNameDebounceTimer = null
}
})
// 格式化 API 格式显示名称

View File

@@ -24,6 +24,7 @@ export interface PaginationParams {
export interface FilterParams {
user_id?: string
user_api_key_name?: string
model?: string
provider?: string
status?: string
@@ -255,6 +256,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.user_id) {
params.user_id = filters.user_id
}
if (filters?.user_api_key_name) {
params.user_api_key_name = filters.user_api_key_name
}
if (filters?.model) {
params.model = filters.model
}

View File

@@ -61,6 +61,11 @@ export interface UsageRecord {
user_id?: string
username?: string
user_email?: string
api_key?: {
id: string | null
name: string | null
display: string | null
} | null
provider: string
api_key_name?: string
rate_multiplier?: number

View File

@@ -367,6 +367,11 @@ function generateMockUsageRecords(count: number = 100) {
user_id: user.id,
username: user.username,
user_email: user.email,
api_key: {
id: `key-${user.id}-${Math.ceil(Math.random() * 2)}`,
name: `${user.username} Key ${Math.ceil(Math.random() * 3)}`,
display: `sk-ae...${String(1000 + Math.floor(Math.random() * 9000))}`
},
provider: model.provider,
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
rate_multiplier: 1.0,
@@ -835,10 +840,17 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
'GET /api/admin/usage/records': async (config) => {
await delay()
requireAdmin()
const records = getUsageRecords()
let records = getUsageRecords()
const params = config.params || {}
const limit = parseInt(params.limit) || 20
const offset = parseInt(params.offset) || 0
// 用户 API Key 名称筛选(注意:不是 Provider Key
if (typeof params.user_api_key_name === 'string' && params.user_api_key_name.trim()) {
const keyword = params.user_api_key_name.trim().toLowerCase()
records = records.filter(r => (r.api_key?.name || '').toLowerCase().includes(keyword))
}
return createMockResponse({
records: records.slice(offset, offset + limit),
total: records.length,

View File

@@ -57,6 +57,7 @@
:loading="isLoadingRecords"
:selected-period="selectedPeriod"
:filter-user="filterUser"
:filter-key-name="filterKeyName"
:filter-model="filterModel"
:filter-provider="filterProvider"
:filter-status="filterStatus"
@@ -70,6 +71,7 @@
:auto-refresh="globalAutoRefresh"
@update:selected-period="handlePeriodChange"
@update:filter-user="handleFilterUserChange"
@update:filter-key-name="handleFilterKeyNameChange"
@update:filter-model="handleFilterModelChange"
@update:filter-provider="handleFilterProviderChange"
@update:filter-status="handleFilterStatusChange"
@@ -134,6 +136,7 @@ const pageSizeOptions = [10, 20, 50, 100]
// 筛选状态
const filterUser = ref('__all__')
const filterKeyName = ref('')
const filterModel = ref('__all__')
const filterProvider = ref('__all__')
const filterStatus = ref<FilterStatusValue>('__all__')
@@ -439,6 +442,7 @@ async function handlePageSizeChange(size: number) {
function getCurrentFilters() {
return {
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
user_api_key_name: filterKeyName.value.trim() ? filterKeyName.value.trim() : undefined,
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
@@ -455,6 +459,15 @@ async function handleFilterUserChange(value: string) {
}
}
async function handleFilterKeyNameChange(value: string) {
filterKeyName.value = value
currentPage.value = 1
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
}
}
async function handleFilterModelChange(value: string) {
filterModel.value = value
currentPage.value = 1 // 重置到第一页

View File

@@ -94,6 +94,7 @@ async def get_usage_records(
end_date: Optional[datetime] = None,
user_id: Optional[str] = None,
username: Optional[str] = None,
user_api_key_name: Optional[str] = None,
model: Optional[str] = None,
provider: Optional[str] = None,
status: Optional[str] = None, # stream, standard, error
@@ -106,6 +107,7 @@ async def get_usage_records(
end_date=end_date,
user_id=user_id,
username=username,
user_api_key_name=user_api_key_name,
model=model,
provider=provider,
status=status,
@@ -502,6 +504,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
end_date: Optional[datetime],
user_id: Optional[str],
username: Optional[str],
user_api_key_name: Optional[str],
model: Optional[str],
provider: Optional[str],
status: Optional[str],
@@ -512,6 +515,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
self.end_date = end_date
self.user_id = user_id
self.username = username
self.user_api_key_name = user_api_key_name
self.model = model
self.provider = provider
self.status = status
@@ -521,16 +525,20 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override]
db = context.db
query = (
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey)
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
.outerjoin(User, Usage.user_id == User.id)
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
)
if self.user_id:
query = query.filter(Usage.user_id == self.user_id)
if self.username:
# 支持用户名模糊搜索
query = query.filter(User.username.ilike(f"%{self.username}%"))
if self.user_api_key_name:
# 支持用户 API Key 名称模糊搜索(注意:不是 Provider Key
query = query.filter(ApiKey.name.ilike(f"%{self.user_api_key_name}%"))
if self.model:
# 支持模型名模糊搜索
query = query.filter(Usage.model.ilike(f"%{self.model}%"))
@@ -575,7 +583,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
)
request_ids = [usage.request_id for usage, _, _, _ in records if usage.request_id]
request_ids = [usage.request_id for usage, _, _, _, _ in records if usage.request_id]
fallback_map = {}
if request_ids:
# 只统计实际执行的候选success 或 failed不包括 skipped/pending/available
@@ -597,6 +605,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
end_date=self.end_date.isoformat() if self.end_date else None,
user_id=self.user_id,
username=self.username,
user_api_key_name=self.user_api_key_name,
model=self.model,
provider=self.provider,
status=self.status,
@@ -606,7 +615,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
)
# 构建 provider_id -> Provider 名称的映射,避免 N+1 查询
provider_ids = [usage.provider_id for usage, _, _, _ in records if usage.provider_id]
provider_ids = [usage.provider_id for usage, _, _, _, _ in records if usage.provider_id]
provider_map = {}
if provider_ids:
providers_data = (
@@ -615,7 +624,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
provider_map = {str(p.id): p.name for p in providers_data}
data = []
for usage, user, endpoint, api_key in records:
for usage, user, endpoint, provider_api_key, user_api_key in records:
actual_cost = (
float(usage.actual_total_cost_usd)
if usage.actual_total_cost_usd is not None
@@ -636,6 +645,15 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"user_id": user.id if user else None,
"user_email": user.email if user else "已删除用户",
"username": user.username if user else "已删除用户",
"api_key": (
{
"id": user_api_key.id,
"name": user_api_key.name,
"display": user_api_key.get_display_key(),
}
if user_api_key
else None
),
"provider": provider_name,
"model": usage.model,
"target_model": usage.target_model, # 映射后的目标模型名
@@ -661,7 +679,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"has_fallback": fallback_map.get(usage.request_id, False),
"api_format": usage.api_format
or (endpoint.api_format if endpoint and endpoint.api_format else None),
"api_key_name": api_key.name if api_key else None,
"api_key_name": provider_api_key.name if provider_api_key else None,
"request_metadata": usage.request_metadata, # Provider 响应元数据
}
)