mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-11 03:58:28 +08:00
feat: add usage statistics and records feature with new API routes, frontend types, services, and UI components
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 格式显示名称
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 // 重置到第一页
|
||||
|
||||
Reference in New Issue
Block a user