mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-08 18:52: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:
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -262,7 +262,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -306,7 +305,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1600,7 +1598,6 @@
|
|||||||
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
"integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.10.0"
|
"undici-types": "~7.10.0"
|
||||||
}
|
}
|
||||||
@@ -1679,7 +1676,6 @@
|
|||||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.49.0",
|
"@typescript-eslint/scope-manager": "8.49.0",
|
||||||
"@typescript-eslint/types": "8.49.0",
|
"@typescript-eslint/types": "8.49.0",
|
||||||
@@ -2008,7 +2004,6 @@
|
|||||||
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
"integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.0.10",
|
"@vitest/utils": "4.0.10",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
@@ -2306,7 +2301,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2608,7 +2602,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.2",
|
"baseline-browser-mapping": "^2.8.2",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
@@ -2725,7 +2718,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -2948,7 +2940,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
@@ -3201,7 +3192,6 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4101,7 +4091,6 @@
|
|||||||
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.23",
|
"@acemir/cssom": "^0.9.23",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.4",
|
"@asamuzakjp/dom-selector": "^6.7.4",
|
||||||
@@ -4666,7 +4655,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4734,7 +4722,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5765,7 +5752,6 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -5859,7 +5845,6 @@
|
|||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -5935,7 +5920,6 @@
|
|||||||
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
"integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.10",
|
"@vitest/expect": "4.0.10",
|
||||||
"@vitest/mocker": "4.0.10",
|
"@vitest/mocker": "4.0.10",
|
||||||
@@ -6020,7 +6004,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
|
||||||
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.21",
|
"@vue/compiler-dom": "3.5.21",
|
||||||
"@vue/compiler-sfc": "3.5.21",
|
"@vue/compiler-sfc": "3.5.21",
|
||||||
@@ -6053,6 +6036,7 @@
|
|||||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"eslint-scope": "^8.2.0",
|
"eslint-scope": "^8.2.0",
|
||||||
@@ -6077,6 +6061,7 @@
|
|||||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export const usageApi = {
|
|||||||
end_date?: string
|
end_date?: string
|
||||||
user_id?: string // UUID
|
user_id?: string // UUID
|
||||||
username?: string
|
username?: string
|
||||||
|
user_api_key_name?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
status?: string // 'stream' | 'standard' | 'error'
|
status?: string // 'stream' | 'standard' | 'error'
|
||||||
|
|||||||
@@ -56,6 +56,20 @@
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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
|
<Select
|
||||||
v-model:open="filterModelSelectOpen"
|
v-model:open="filterModelSelectOpen"
|
||||||
@@ -218,7 +232,18 @@
|
|||||||
class="py-4 w-[100px] truncate"
|
class="py-4 w-[100px] truncate"
|
||||||
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
: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>
|
||||||
<TableCell
|
<TableCell
|
||||||
class="font-medium py-4 w-[140px]"
|
class="font-medium py-4 w-[140px]"
|
||||||
@@ -438,6 +463,7 @@ import {
|
|||||||
TableCard,
|
TableCard,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Input,
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
@@ -451,7 +477,7 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { RefreshCcw } from 'lucide-vue-next'
|
import { RefreshCcw, Search } from 'lucide-vue-next'
|
||||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||||
import { formatDateTime } from '../composables'
|
import { formatDateTime } from '../composables'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
@@ -472,6 +498,7 @@ const props = defineProps<{
|
|||||||
selectedPeriod: string
|
selectedPeriod: string
|
||||||
// 筛选
|
// 筛选
|
||||||
filterUser: string
|
filterUser: string
|
||||||
|
filterKeyName: string
|
||||||
filterModel: string
|
filterModel: string
|
||||||
filterProvider: string
|
filterProvider: string
|
||||||
filterStatus: string
|
filterStatus: string
|
||||||
@@ -490,6 +517,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:selectedPeriod': [value: string]
|
'update:selectedPeriod': [value: string]
|
||||||
'update:filterUser': [value: string]
|
'update:filterUser': [value: string]
|
||||||
|
'update:filterKeyName': [value: string]
|
||||||
'update:filterModel': [value: string]
|
'update:filterModel': [value: string]
|
||||||
'update:filterProvider': [value: string]
|
'update:filterProvider': [value: string]
|
||||||
'update:filterStatus': [value: string]
|
'update:filterStatus': [value: string]
|
||||||
@@ -507,6 +535,23 @@ const filterModelSelectOpen = ref(false)
|
|||||||
const filterProviderSelectOpen = ref(false)
|
const filterProviderSelectOpen = ref(false)
|
||||||
const filterStatusSelectOpen = 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())
|
const now = ref(Date.now())
|
||||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||||
@@ -574,6 +619,10 @@ function handleRowClick(event: MouseEvent, id: string) {
|
|||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
if (keyNameDebounceTimer) {
|
||||||
|
clearTimeout(keyNameDebounceTimer)
|
||||||
|
keyNameDebounceTimer = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化 API 格式显示名称
|
// 格式化 API 格式显示名称
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface PaginationParams {
|
|||||||
|
|
||||||
export interface FilterParams {
|
export interface FilterParams {
|
||||||
user_id?: string
|
user_id?: string
|
||||||
|
user_api_key_name?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
status?: string
|
status?: string
|
||||||
@@ -255,6 +256,9 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
if (filters?.user_id) {
|
if (filters?.user_id) {
|
||||||
params.user_id = 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) {
|
if (filters?.model) {
|
||||||
params.model = filters.model
|
params.model = filters.model
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export interface UsageRecord {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
username?: string
|
username?: string
|
||||||
user_email?: string
|
user_email?: string
|
||||||
|
api_key?: {
|
||||||
|
id: string | null
|
||||||
|
name: string | null
|
||||||
|
display: string | null
|
||||||
|
} | null
|
||||||
provider: string
|
provider: string
|
||||||
api_key_name?: string
|
api_key_name?: string
|
||||||
rate_multiplier?: number
|
rate_multiplier?: number
|
||||||
|
|||||||
@@ -367,6 +367,11 @@ function generateMockUsageRecords(count: number = 100) {
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
user_email: user.email,
|
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,
|
provider: model.provider,
|
||||||
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
|
api_key_name: `${model.provider}-key-${Math.ceil(Math.random() * 3)}`,
|
||||||
rate_multiplier: 1.0,
|
rate_multiplier: 1.0,
|
||||||
@@ -835,10 +840,17 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
|||||||
'GET /api/admin/usage/records': async (config) => {
|
'GET /api/admin/usage/records': async (config) => {
|
||||||
await delay()
|
await delay()
|
||||||
requireAdmin()
|
requireAdmin()
|
||||||
const records = getUsageRecords()
|
let records = getUsageRecords()
|
||||||
const params = config.params || {}
|
const params = config.params || {}
|
||||||
const limit = parseInt(params.limit) || 20
|
const limit = parseInt(params.limit) || 20
|
||||||
const offset = parseInt(params.offset) || 0
|
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({
|
return createMockResponse({
|
||||||
records: records.slice(offset, offset + limit),
|
records: records.slice(offset, offset + limit),
|
||||||
total: records.length,
|
total: records.length,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
:loading="isLoadingRecords"
|
:loading="isLoadingRecords"
|
||||||
:selected-period="selectedPeriod"
|
:selected-period="selectedPeriod"
|
||||||
:filter-user="filterUser"
|
:filter-user="filterUser"
|
||||||
|
:filter-key-name="filterKeyName"
|
||||||
:filter-model="filterModel"
|
:filter-model="filterModel"
|
||||||
:filter-provider="filterProvider"
|
:filter-provider="filterProvider"
|
||||||
:filter-status="filterStatus"
|
:filter-status="filterStatus"
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
:auto-refresh="globalAutoRefresh"
|
:auto-refresh="globalAutoRefresh"
|
||||||
@update:selected-period="handlePeriodChange"
|
@update:selected-period="handlePeriodChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
|
@update:filter-key-name="handleFilterKeyNameChange"
|
||||||
@update:filter-model="handleFilterModelChange"
|
@update:filter-model="handleFilterModelChange"
|
||||||
@update:filter-provider="handleFilterProviderChange"
|
@update:filter-provider="handleFilterProviderChange"
|
||||||
@update:filter-status="handleFilterStatusChange"
|
@update:filter-status="handleFilterStatusChange"
|
||||||
@@ -134,6 +136,7 @@ const pageSizeOptions = [10, 20, 50, 100]
|
|||||||
|
|
||||||
// 筛选状态
|
// 筛选状态
|
||||||
const filterUser = ref('__all__')
|
const filterUser = ref('__all__')
|
||||||
|
const filterKeyName = ref('')
|
||||||
const filterModel = ref('__all__')
|
const filterModel = ref('__all__')
|
||||||
const filterProvider = ref('__all__')
|
const filterProvider = ref('__all__')
|
||||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||||
@@ -439,6 +442,7 @@ async function handlePageSizeChange(size: number) {
|
|||||||
function getCurrentFilters() {
|
function getCurrentFilters() {
|
||||||
return {
|
return {
|
||||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
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,
|
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||||
status: filterStatus.value !== '__all__' ? filterStatus.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) {
|
async function handleFilterModelChange(value: string) {
|
||||||
filterModel.value = value
|
filterModel.value = value
|
||||||
currentPage.value = 1 // 重置到第一页
|
currentPage.value = 1 // 重置到第一页
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ async def get_usage_records(
|
|||||||
end_date: Optional[datetime] = None,
|
end_date: Optional[datetime] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
|
user_api_key_name: Optional[str] = None,
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
provider: Optional[str] = None,
|
provider: Optional[str] = None,
|
||||||
status: Optional[str] = None, # stream, standard, error
|
status: Optional[str] = None, # stream, standard, error
|
||||||
@@ -106,6 +107,7 @@ async def get_usage_records(
|
|||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=username,
|
username=username,
|
||||||
|
user_api_key_name=user_api_key_name,
|
||||||
model=model,
|
model=model,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
status=status,
|
status=status,
|
||||||
@@ -502,6 +504,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
end_date: Optional[datetime],
|
end_date: Optional[datetime],
|
||||||
user_id: Optional[str],
|
user_id: Optional[str],
|
||||||
username: Optional[str],
|
username: Optional[str],
|
||||||
|
user_api_key_name: Optional[str],
|
||||||
model: Optional[str],
|
model: Optional[str],
|
||||||
provider: Optional[str],
|
provider: Optional[str],
|
||||||
status: Optional[str],
|
status: Optional[str],
|
||||||
@@ -512,6 +515,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
self.end_date = end_date
|
self.end_date = end_date
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.username = username
|
self.username = username
|
||||||
|
self.user_api_key_name = user_api_key_name
|
||||||
self.model = model
|
self.model = model
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.status = status
|
self.status = status
|
||||||
@@ -521,16 +525,20 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
db = context.db
|
db = context.db
|
||||||
query = (
|
query = (
|
||||||
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey)
|
db.query(Usage, User, ProviderEndpoint, ProviderAPIKey, ApiKey)
|
||||||
.outerjoin(User, Usage.user_id == User.id)
|
.outerjoin(User, Usage.user_id == User.id)
|
||||||
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
||||||
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
|
.outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
|
||||||
|
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||||
)
|
)
|
||||||
if self.user_id:
|
if self.user_id:
|
||||||
query = query.filter(Usage.user_id == self.user_id)
|
query = query.filter(Usage.user_id == self.user_id)
|
||||||
if self.username:
|
if self.username:
|
||||||
# 支持用户名模糊搜索
|
# 支持用户名模糊搜索
|
||||||
query = query.filter(User.username.ilike(f"%{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:
|
if self.model:
|
||||||
# 支持模型名模糊搜索
|
# 支持模型名模糊搜索
|
||||||
query = query.filter(Usage.model.ilike(f"%{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()
|
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 = {}
|
fallback_map = {}
|
||||||
if request_ids:
|
if request_ids:
|
||||||
# 只统计实际执行的候选(success 或 failed),不包括 skipped/pending/available
|
# 只统计实际执行的候选(success 或 failed),不包括 skipped/pending/available
|
||||||
@@ -597,6 +605,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
end_date=self.end_date.isoformat() if self.end_date else None,
|
end_date=self.end_date.isoformat() if self.end_date else None,
|
||||||
user_id=self.user_id,
|
user_id=self.user_id,
|
||||||
username=self.username,
|
username=self.username,
|
||||||
|
user_api_key_name=self.user_api_key_name,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
status=self.status,
|
status=self.status,
|
||||||
@@ -606,7 +615,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 构建 provider_id -> Provider 名称的映射,避免 N+1 查询
|
# 构建 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 = {}
|
provider_map = {}
|
||||||
if provider_ids:
|
if provider_ids:
|
||||||
providers_data = (
|
providers_data = (
|
||||||
@@ -615,7 +624,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
provider_map = {str(p.id): p.name for p in providers_data}
|
provider_map = {str(p.id): p.name for p in providers_data}
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
for usage, user, endpoint, api_key in records:
|
for usage, user, endpoint, provider_api_key, user_api_key in records:
|
||||||
actual_cost = (
|
actual_cost = (
|
||||||
float(usage.actual_total_cost_usd)
|
float(usage.actual_total_cost_usd)
|
||||||
if usage.actual_total_cost_usd is not None
|
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_id": user.id if user else None,
|
||||||
"user_email": user.email if user else "已删除用户",
|
"user_email": user.email if user else "已删除用户",
|
||||||
"username": user.username 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,
|
"provider": provider_name,
|
||||||
"model": usage.model,
|
"model": usage.model,
|
||||||
"target_model": usage.target_model, # 映射后的目标模型名
|
"target_model": usage.target_model, # 映射后的目标模型名
|
||||||
@@ -661,7 +679,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
|
|||||||
"has_fallback": fallback_map.get(usage.request_id, False),
|
"has_fallback": fallback_map.get(usage.request_id, False),
|
||||||
"api_format": usage.api_format
|
"api_format": usage.api_format
|
||||||
or (endpoint.api_format if endpoint and endpoint.api_format else None),
|
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 响应元数据
|
"request_metadata": usage.request_metadata, # Provider 响应元数据
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user