diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 822f41a8..924f5fb6 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -85,9 +85,40 @@ -
+
- + +
+ +
@@ -166,6 +197,7 @@ const filters = toRef(props, 'modelValue') const userSearchRef = ref(null) const apiKeySearchRef = ref(null) +const accountSearchRef = ref(null) const userKeyword = ref('') const userResults = ref([]) @@ -177,9 +209,17 @@ const apiKeyResults = ref([]) const showApiKeyDropdown = ref(false) let apiKeySearchTimeout: ReturnType | null = null +interface SimpleAccount { + id: number + name: string +} +const accountKeyword = ref('') +const accountResults = ref([]) +const showAccountDropdown = ref(false) +let accountSearchTimeout: ReturnType | null = null + const modelOptions = ref([{ value: null, label: t('admin.usage.allModels') }]) const groupOptions = ref([{ value: null, label: t('admin.usage.allGroups') }]) -const accountOptions = ref([{ value: null, label: t('admin.usage.allAccounts') }]) const streamTypeOptions = ref([ { value: null, label: t('admin.usage.allTypes') }, @@ -278,6 +318,37 @@ const onClearApiKey = () => { emitChange() } +const debounceAccountSearch = () => { + if (accountSearchTimeout) clearTimeout(accountSearchTimeout) + accountSearchTimeout = setTimeout(async () => { + if (!accountKeyword.value) { + accountResults.value = [] + return + } + try { + const res = await adminAPI.accounts.list(1, 20, { search: accountKeyword.value }) + accountResults.value = res.items.map((a) => ({ id: a.id, name: a.name })) + } catch { + accountResults.value = [] + } + }, 300) +} + +const selectAccount = (a: SimpleAccount) => { + accountKeyword.value = a.name + showAccountDropdown.value = false + filters.value.account_id = a.id + emitChange() +} + +const clearAccount = () => { + accountKeyword.value = '' + accountResults.value = [] + showAccountDropdown.value = false + filters.value.account_id = undefined + emitChange() +} + const onApiKeyFocus = () => { showApiKeyDropdown.value = true // Trigger search if no results yet @@ -292,9 +363,11 @@ const onDocumentClick = (e: MouseEvent) => { const clickedInsideUser = userSearchRef.value?.contains(target) ?? false const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false + const clickedInsideAccount = accountSearchRef.value?.contains(target) ?? false if (!clickedInsideUser) showUserDropdown.value = false if (!clickedInsideApiKey) showApiKeyDropdown.value = false + if (!clickedInsideAccount) showAccountDropdown.value = false } watch( @@ -333,20 +406,27 @@ watch( } ) +watch( + () => filters.value.account_id, + (accountId) => { + if (!accountId) { + accountKeyword.value = '' + accountResults.value = [] + } + } +) + onMounted(async () => { document.addEventListener('click', onDocumentClick) try { - const [gs, ms, as] = await Promise.all([ + const [gs, ms] = await Promise.all([ adminAPI.groups.list(1, 1000), - adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }), - adminAPI.accounts.list(1, 1000) + adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }) ]) groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name }))) - accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name }))) - const uniqueModels = new Set() ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model)) modelOptions.value.push( diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index fd5768a9..79465bb7 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -143,8 +143,8 @@ >
-
-
Token {{ t('usage.details') }}
+
+
{{ t('usage.tokenDetails') }}
{{ t('admin.usage.inputTokens') }} {{ tokenTooltipData.input_tokens.toLocaleString() }} @@ -184,6 +184,27 @@ >
+ +
+
{{ t('usage.costDetails') }}
+
+ {{ t('admin.usage.inputCost') }} + ${{ tooltipData.input_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.outputCost') }} + ${{ tooltipData.output_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheCreationCost') }} + ${{ tooltipData.cache_creation_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheReadCost') }} + ${{ tooltipData.cache_read_cost.toFixed(6) }} +
+
+
{{ t('usage.rate') }} {{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 393641a7..4634d8b6 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -376,6 +376,8 @@ export default { usage: { title: 'Usage Records', description: 'View and analyze your API usage history', + costDetails: 'Cost Breakdown', + tokenDetails: 'Token Breakdown', totalRequests: 'Total Requests', totalTokens: 'Total Tokens', totalCost: 'Total Cost', @@ -1691,6 +1693,7 @@ export default { userFilter: 'User', searchUserPlaceholder: 'Search user by email...', searchApiKeyPlaceholder: 'Search API key by name...', + searchAccountPlaceholder: 'Search account by name...', selectedUser: 'Selected', user: 'User', account: 'Account', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fb46bbbe..7e326bab 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -373,6 +373,8 @@ export default { usage: { title: '使用记录', description: '查看和分析您的 API 使用历史', + costDetails: '成本明细', + tokenDetails: 'Token 明细', totalRequests: '总请求数', totalTokens: '总 Token', totalCost: '总消费', @@ -1836,6 +1838,7 @@ export default { userFilter: '用户', searchUserPlaceholder: '按邮箱搜索用户...', searchApiKeyPlaceholder: '按名称搜索 API 密钥...', + searchAccountPlaceholder: '按名称搜索账号...', selectedUser: '已选择', user: '用户', account: '账户', diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index d5e94145..522f1b00 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -85,11 +85,48 @@ const exportToExcel = async () => { if (all.length >= total || res.items.length < 100) break; p++ } if(!c.signal.aborted) { - // 动态加载 xlsx,降低首屏包体并减少高危依赖的常驻暴露面。 const XLSX = await import('xlsx') - const ws = XLSX.utils.json_to_sheet(all); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Usage') - saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${Date.now()}.xlsx`) - appStore.showSuccess('Export Success') + const headers = [ + t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'), + t('admin.usage.account'), t('usage.model'), t('admin.usage.group'), + t('usage.type'), + t('admin.usage.inputTokens'), t('admin.usage.outputTokens'), + t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'), + t('admin.usage.inputCost'), t('admin.usage.outputCost'), + t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'), + t('usage.rate'), t('usage.original'), t('usage.billed'), + t('usage.billingType'), t('usage.firstToken'), t('usage.duration'), + t('admin.usage.requestId') + ] + const rows = all.map(log => [ + log.created_at, + log.user?.email || '', + log.api_key?.name || '', + log.account?.name || '', + log.model, + log.group?.name || '', + log.stream ? t('usage.stream') : t('usage.sync'), + log.input_tokens, + log.output_tokens, + log.cache_read_tokens, + log.cache_creation_tokens, + log.input_cost?.toFixed(6) || '0.000000', + log.output_cost?.toFixed(6) || '0.000000', + log.cache_read_cost?.toFixed(6) || '0.000000', + log.cache_creation_cost?.toFixed(6) || '0.000000', + log.rate_multiplier?.toFixed(2) || '1.00', + log.total_cost?.toFixed(6) || '0.000000', + log.actual_cost?.toFixed(6) || '0.000000', + log.billing_type === 1 ? t('usage.subscription') : t('usage.balance'), + log.first_token_ms ?? '', + log.duration_ms, + log.request_id || '' + ]) + const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Usage') + saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${filters.value.start_date}_to_${filters.value.end_date}.xlsx`) + appStore.showSuccess(t('usage.exportSuccess')) } } catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') } finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } } diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 567d4061..489e2726 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -342,8 +342,8 @@ >
-
-
Token 明细
+
+
{{ t('usage.tokenDetails') }}
{{ t('admin.usage.inputTokens') }} {{ tokenTooltipData.input_tokens.toLocaleString() }} @@ -389,6 +389,27 @@ 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" >
+ +
+
{{ t('usage.costDetails') }}
+
+ {{ t('admin.usage.inputCost') }} + ${{ tooltipData.input_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.outputCost') }} + ${{ tooltipData.output_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheCreationCost') }} + ${{ tooltipData.cache_creation_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheReadCost') }} + ${{ tooltipData.cache_read_cost.toFixed(6) }} +
+
+
{{ t('usage.rate') }}