feat(pool): 记录并展示 Provider Key 累计 Token 与费用

Closes #219

Co-authored-by: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-03-11 15:56:58 +08:00
parent 02e2f4f500
commit 380d69e096
10 changed files with 452 additions and 16 deletions

View File

@@ -29,7 +29,7 @@ export interface PoolStatusResponse {
* 获取 Provider 的号池状态
*/
export async function getPoolStatus(providerId: string): Promise<PoolStatusResponse> {
const response = await client.get(`/api/admin/providers/${providerId}/pool-status`)
const response = await client.get<PoolStatusResponse>(`/api/admin/providers/${providerId}/pool-status`)
return response.data
}
@@ -40,7 +40,7 @@ export async function clearPoolCooldown(
providerId: string,
keyId: string,
): Promise<{ message: string }> {
const response = await client.post(
const response = await client.post<{ message: string }>(
`/api/admin/providers/${providerId}/pool/clear-cooldown/${keyId}`,
)
return response.data
@@ -53,7 +53,7 @@ export async function resetPoolCost(
providerId: string,
keyId: string,
): Promise<{ message: string }> {
const response = await client.post(
const response = await client.post<{ message: string }>(
`/api/admin/providers/${providerId}/pool/reset-cost/${keyId}`,
)
return response.data
@@ -125,6 +125,8 @@ export interface PoolKeyDetail {
cost_window_usage: number
cost_limit: number | null
request_count: number
total_tokens: number
total_cost_usd: string
sticky_sessions: number
lru_score: number | null
created_at: string | null

View File

@@ -543,13 +543,25 @@
>-</span>
</TableCell>
<TableCell class="py-3 px-2 align-middle">
<div class="w-[136px] mx-auto text-[10px] leading-4">
<div class="grid grid-rows-3 gap-0.5 w-[136px] mx-auto text-[10px] leading-4">
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">请求</span>
<span class="tabular-nums text-foreground/90">
{{ formatStatInteger(key.request_count) }}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">Token</span>
<span class="tabular-nums text-foreground/90">
{{ formatTokenCount(key.total_tokens) }}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">费用</span>
<span class="tabular-nums text-foreground/90">
{{ formatStatUsd(key.total_cost_usd) }}
</span>
</div>
</div>
</TableCell>
<TableCell class="py-3 text-center">
@@ -912,11 +924,19 @@
<div class="text-muted-foreground mb-0.5">
统计
</div>
<div class="text-[10px]">
<div class="space-y-0.5 text-[10px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">请求</span>
<span class="tabular-nums">{{ formatStatInteger(key.request_count) }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">Token</span>
<span class="tabular-nums">{{ formatTokenCount(key.total_tokens) }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">费用</span>
<span class="tabular-nums">{{ formatStatUsd(key.total_cost_usd) }}</span>
</div>
</div>
</div>
<div
@@ -1195,6 +1215,7 @@ async function loadOverview() {
selectedProviderData.value = null
showAccountBatchDialog.value = false
closeProviderProxyPopovers()
resetKeyPage()
}
}
} catch (err) {
@@ -1382,6 +1403,7 @@ const desktopColumnWidths = computed(() => {
async function selectProvider(id: string) {
const requestId = ++selectProviderRequestId
selectedProviderId.value = id
selectedProviderData.value = null
editingKeyDetail.value = null
showAccountBatchDialog.value = false
keyPermissionsDialogOpen.value = false
@@ -1399,6 +1421,7 @@ async function selectProvider(id: string) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
resetKeyPage(1, pageSize.value)
const keysTask = loadKeys()
// Provider summary is non-blocking for key list rendering.
void loadProviderData(id)
@@ -1423,7 +1446,11 @@ async function refresh() {
}
// --- Keys ---
const keyPage = ref<PoolKeysPageResponse>({ total: 0, page: 1, page_size: 50, keys: [] })
function createEmptyKeyPage(page = 1, pageSizeValue = 50): PoolKeysPageResponse {
return { total: 0, page, page_size: pageSizeValue, keys: [] }
}
const keyPage = ref<PoolKeysPageResponse>(createEmptyKeyPage())
const keysLoading = ref(false)
const refreshingCurrentPageQuota = ref(false)
const searchQuery = ref('')
@@ -1432,7 +1459,6 @@ const currentPage = ref(1)
const pageSize = ref(50)
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
const refreshingOAuthKeyId = ref<string | null>(null)
const revealedKeys = ref<Map<string, string>>(new Map())
const recoveringHealthKeyId = ref<string | null>(null)
const savingProxyKeyId = ref<string | null>(null)
const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
@@ -1473,6 +1499,14 @@ const refreshCurrentPageLoading = computed(() => {
return keysLoading.value || refreshingCurrentPageQuota.value
})
function resetKeyPage(page = currentPage.value, pageSizeValue = pageSize.value): void {
keyPage.value = createEmptyKeyPage(page, pageSizeValue)
}
function refreshOverviewInBackground(): void {
void loadOverview()
}
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
const value = Number(raw ?? 0)
if (!Number.isFinite(value) || value <= 0) return null
@@ -1608,6 +1642,7 @@ async function loadKeys() {
keyPage.value = nextPage
} catch (err) {
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
resetKeyPage(page, pageSizeValue)
showError(parseApiError(err))
} finally {
if (requestId === keysRequestId) {
@@ -1879,6 +1914,7 @@ async function handleDeleteKey(key: PoolKeyDetail) {
if (keyPage.value.keys.length === 0 && currentPage.value > 1) {
currentPage.value--
}
refreshOverviewInBackground()
} catch (err) {
showError(parseApiError(err, '删除账号失败'))
} finally {
@@ -1887,12 +1923,6 @@ async function handleDeleteKey(key: PoolKeyDetail) {
}
async function copyFullKey(key: PoolKeyDetail) {
const cached = revealedKeys.value.get(key.key_id)
if (cached) {
await copyToClipboard(cached)
return
}
try {
const result = await revealEndpointKey(key.key_id)
let textToCopy = ''
@@ -1912,7 +1942,6 @@ async function copyFullKey(key: PoolKeyDetail) {
return
}
revealedKeys.value.set(key.key_id, textToCopy)
await copyToClipboard(textToCopy)
} catch (err) {
showError(parseApiError(err, '获取密钥失败'))
@@ -1969,6 +1998,7 @@ async function clearCooldown(keyId: string) {
const res = await clearPoolCooldown(selectedProviderId.value, keyId)
success(res.message)
await loadKeys()
refreshOverviewInBackground()
} catch (err) {
showError(parseApiError(err))
}
@@ -1994,6 +2024,7 @@ async function toggleKeyActive(key: PoolKeyDetail) {
}
success(nextStatus ? '账号已启用' : '账号已停用')
await loadKeys()
refreshOverviewInBackground()
} catch (err) {
showError(parseApiError(err))
} finally {
@@ -2496,6 +2527,23 @@ function formatStatInteger(value: number | null | undefined): string {
return Math.round(n).toLocaleString('en-US')
}
function formatTokenCount(value: number | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '0'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(Math.round(n))
}
function formatStatUsd(value: number | string | null | undefined): string {
const n = Number(value ?? 0)
if (!Number.isFinite(n) || n <= 0) return '$0.00'
if (n < 0.01) return `$${n.toFixed(4)}`
if (n < 1) return `$${n.toFixed(3)}`
if (n < 1000) return `$${n.toFixed(2)}`
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatRelativeTime(isoStr: string): string {
const date = new Date(isoStr)
const pad = (n: number) => String(n).padStart(2, '0')