feat(usage): 用户 API Key 用量页支持按日明细

This commit is contained in:
wucm667
2026-05-20 15:48:38 +08:00
parent 7ec61eb2f5
commit 90b2b2a757
11 changed files with 738 additions and 14 deletions
+37
View File
@@ -69,6 +69,25 @@ export interface ModelStatsResponse {
end_date: string
}
export interface ApiKeyDailyUsagePoint {
date: string
requests: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
total_tokens: number
cost: number
actual_cost: number
}
export interface ApiKeyDailyUsageResponse {
items: ApiKeyDailyUsagePoint[]
days: number
start_date: string
end_date: string
}
/**
* List usage logs with optional filters
* @param page - Page number (default: 1)
@@ -234,6 +253,23 @@ export async function getDashboardModels(params?: {
return data
}
/**
* Get daily usage details for one API key owned by the current user.
* @param apiKeyId - API key ID
* @param days - Number of days to include (1-90)
* @returns Daily usage detail rows
*/
export async function getMyApiKeyDailyUsage(
apiKeyId: number,
days: number = 30
): Promise<ApiKeyDailyUsageResponse> {
const { data } = await apiClient.get<ApiKeyDailyUsageResponse>(
`/user/api-keys/${apiKeyId}/usage/daily`,
{ params: { days } }
)
return data
}
export interface BatchApiKeyUsageStats {
api_key_id: number
today_actual_cost: number
@@ -279,6 +315,7 @@ export const usageAPI = {
getDashboardStats,
getDashboardTrend,
getDashboardModels,
getMyApiKeyDailyUsage,
getDashboardApiKeysUsage
}
+21
View File
@@ -122,19 +122,23 @@ export default {
dateRangeToday: 'Today',
dateRange7d: '7 Days',
dateRange30d: '30 Days',
dateRange90d: '90 Days',
dateRangeCustom: 'Custom',
apply: 'Apply',
used: 'Used',
detailInfo: 'Detail Information',
tokenStats: 'Token Statistics',
dailyDetail: 'Daily Detail',
modelStats: 'Model Usage Statistics',
// Table headers
date: 'Date',
model: 'Model',
requests: 'Requests',
inputTokens: 'Input Tokens',
outputTokens: 'Output Tokens',
cacheCreationTokens: 'Cache Creation',
cacheReadTokens: 'Cache Read',
cacheWriteTokens: 'Cache Write',
totalTokens: 'Total Tokens',
cost: 'Cost',
// Status
@@ -178,6 +182,7 @@ export default {
querySuccess: 'Query successful',
queryFailed: 'Query failed',
queryFailedRetry: 'Query failed, please try again later',
noDailyUsage: 'No daily usage data',
},
// Setup Wizard
@@ -4148,6 +4153,22 @@ export default {
},
userPrefix: 'User #{id}',
exportCsv: 'Export CSV',
batchUpdate: 'Batch Update',
batchUpdateTitle: 'Batch Update Redeem Codes',
selectedCount: '{count} redeem code(s) selected',
clearSelection: 'Clear selection',
selectCodesFirst: 'Select redeem codes first',
noBatchFieldsSelected: 'Select at least one field to update',
batchUpdateSuccess: 'Updated {count} redeem code(s)',
failedToBatchUpdate: 'Failed to batch update redeem codes',
batchFields: {
status: 'Status',
expiresAt: 'Expires At',
notes: 'Notes',
group: 'Group'
},
batchNotesPlaceholder: 'Enter the new note, or leave blank to clear it',
clearGroup: 'Clear group',
deleteAllUnused: 'Delete All Unused Codes',
deleteCode: 'Delete Redeem Code',
deleteCodeConfirm:
+21
View File
@@ -122,19 +122,23 @@ export default {
dateRangeToday: '今日',
dateRange7d: '7 天',
dateRange30d: '30 天',
dateRange90d: '90 天',
dateRangeCustom: '自定义',
apply: '应用',
used: '已使用',
detailInfo: '详细信息',
tokenStats: 'Token 统计',
dailyDetail: '按日明细',
modelStats: '模型用量统计',
// Table headers
date: '日期',
model: '模型',
requests: '请求数',
inputTokens: '输入 Tokens',
outputTokens: '输出 Tokens',
cacheCreationTokens: '缓存创建',
cacheReadTokens: '缓存读取',
cacheWriteTokens: '缓存写入',
totalTokens: '总 Tokens',
cost: '费用',
// Status
@@ -178,6 +182,7 @@ export default {
querySuccess: '查询成功',
queryFailed: '查询失败',
queryFailedRetry: '查询失败,请稍后重试',
noDailyUsage: '暂无按日用量数据',
},
// Setup Wizard
@@ -4282,6 +4287,22 @@ export default {
used: '已使用',
searchCodes: '搜索兑换码或邮箱...',
exportCsv: '导出 CSV',
batchUpdate: '批量修改',
batchUpdateTitle: '批量修改兑换码',
selectedCount: '已选择 {count} 个兑换码',
clearSelection: '清空选择',
selectCodesFirst: '请先选择兑换码',
noBatchFieldsSelected: '请至少勾选一个要修改的字段',
batchUpdateSuccess: '成功修改 {count} 个兑换码',
failedToBatchUpdate: '批量修改兑换码失败',
batchFields: {
status: '状态',
expiresAt: '过期时间',
notes: '备注',
group: '分组'
},
batchNotesPlaceholder: '输入新的备注,留空可清空备注',
clearGroup: '清空分组',
deleteAllUnused: '删除全部未使用',
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
deleteAllUnusedConfirm: '确定要删除全部未使用的兑换码吗?此操作无法撤销。',
+112 -10
View File
@@ -289,6 +289,62 @@
</div>
</div>
<!-- Daily Usage Table -->
<div
v-if="showDailyUsage"
class="fade-up fade-up-delay-4 rounded-2xl border border-gray-200 bg-white/90 backdrop-blur-sm overflow-hidden dark:border-dark-700 dark:bg-dark-900/90"
>
<div class="flex flex-col gap-3 px-8 py-5 border-b border-gray-200 dark:border-dark-700 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.dailyDetail') }}</h3>
<div class="inline-flex rounded-lg border border-gray-200 bg-white p-0.5 dark:border-dark-700 dark:bg-dark-950">
<button
v-for="option in dailyUsageOptions"
:key="option.value"
@click="setDailyUsageDays(option.value)"
class="min-w-12 rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
:class="dailyUsageDays === option.value
? 'bg-primary-500 text-white'
: 'text-gray-600 hover:bg-gray-100 dark:text-dark-300 dark:hover:bg-dark-800'"
>
{{ option.label }}
</button>
</div>
</div>
<div v-if="dailyUsageRows.length > 0" class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-950">
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.date') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.requests') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.inputTokens') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.outputTokens') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cacheReadTokens') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cacheWriteTokens') }}</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400">{{ t('keyUsage.cost') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in dailyUsageRows"
:key="row.date"
class="border-b border-gray-100 last:border-b-0 dark:border-dark-800"
>
<td class="px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white">{{ row.date }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.requests) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.input_tokens) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.output_tokens) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.cache_read_tokens) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right text-gray-700 dark:text-dark-200">{{ fmtNum(row.cache_write_tokens) }}</td>
<td class="px-4 py-3 text-sm tabular-nums text-right font-medium text-gray-900 dark:text-white">{{ usd(row.actual_cost != null ? row.actual_cost : row.cost) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="px-8 py-8 text-center text-sm text-gray-500 dark:text-dark-400">
{{ t('keyUsage.noDailyUsage') }}
</div>
</div>
<!-- Model Stats Table -->
<div
v-if="modelStats.length > 0"
@@ -408,6 +464,7 @@ type DateRangeKey = 'today' | '7d' | '30d' | 'custom'
const currentRange = ref<DateRangeKey>('today')
const customStartDate = ref('')
const customEndDate = ref('')
const dailyUsageDays = ref<7 | 30 | 90>(30)
const dateRanges = computed(() => [
{ key: 'today' as const, label: t('keyUsage.dateRangeToday') },
@@ -416,6 +473,12 @@ const dateRanges = computed(() => [
{ key: 'custom' as const, label: t('keyUsage.dateRangeCustom') },
])
const dailyUsageOptions = computed(() => [
{ value: 7 as const, label: t('keyUsage.dateRange7d') },
{ value: 30 as const, label: t('keyUsage.dateRange30d') },
{ value: 90 as const, label: t('keyUsage.dateRange90d') },
])
function setDateRange(key: DateRangeKey) {
currentRange.value = key
if (key !== 'custom') {
@@ -426,23 +489,36 @@ function setDateRange(key: DateRangeKey) {
function getDateParams(): string {
const now = new Date()
const fmt = (d: Date) => d.toISOString().split('T')[0]
const params = new URLSearchParams()
if (currentRange.value === 'custom') {
if (customStartDate.value && customEndDate.value) {
return `start_date=${customStartDate.value}&end_date=${customEndDate.value}`
params.set('start_date', customStartDate.value)
params.set('end_date', customEndDate.value)
}
return ''
} else {
const end = fmt(now)
let start: string
switch (currentRange.value) {
case 'today': start = end; break
case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break
case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break
default: start = fmt(new Date(now.getTime() - 30 * 86400000))
}
params.set('start_date', start)
params.set('end_date', end)
}
params.set('days', String(dailyUsageDays.value))
params.set('timezone', getBrowserTimezone())
return params.toString()
}
const end = fmt(now)
let start: string
switch (currentRange.value) {
case 'today': start = end; break
case '7d': start = fmt(new Date(now.getTime() - 7 * 86400000)); break
case '30d': start = fmt(new Date(now.getTime() - 30 * 86400000)); break
default: start = fmt(new Date(now.getTime() - 30 * 86400000))
function setDailyUsageDays(days: 7 | 30 | 90) {
if (dailyUsageDays.value === days) return
dailyUsageDays.value = days
if (resultData.value && apiKey.value.trim()) {
queryKey()
}
return `start_date=${start}&end_date=${end}`
}
// ==================== Ring Animation ====================
@@ -731,6 +807,24 @@ const usageStatCells = computed<StatCell[]>(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modelStats = computed<any[]>(() => resultData.value?.model_stats || [])
interface DailyUsageRow {
date: string
requests: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
cost: number
actual_cost?: number
}
const dailyUsageRows = computed<DailyUsageRow[]>(() => {
const rows = resultData.value?.daily_usage
return Array.isArray(rows) ? rows : []
})
const showDailyUsage = computed(() => Boolean(resultData.value && Array.isArray(resultData.value.daily_usage)))
// ==================== Utility Functions ====================
function usd(value: number | null | undefined): string {
@@ -750,6 +844,14 @@ function formatDate(iso: string | null | undefined): string {
return d.toLocaleDateString(loc, { year: 'numeric', month: 'long', day: 'numeric' })
}
function getBrowserTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
// ==================== API Query ====================
async function fetchUsage(key: string) {
@@ -0,0 +1,208 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import KeyUsageView from '../KeyUsageView.vue'
const { showInfo, showSuccess, showError, fetchPublicSettings } = vi.hoisted(() => ({
showInfo: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn(),
fetchPublicSettings: vi.fn(),
}))
const messages: Record<string, string> = {
'keyUsage.title': 'API Key Usage',
'keyUsage.subtitle': 'Usage status',
'keyUsage.placeholder': 'sk-test',
'keyUsage.query': 'Query',
'keyUsage.querying': 'Querying...',
'keyUsage.privacyNote': 'Privacy note',
'keyUsage.dateRange': 'Date Range:',
'keyUsage.dateRangeToday': 'Today',
'keyUsage.dateRange7d': '7 Days',
'keyUsage.dateRange30d': '30 Days',
'keyUsage.dateRange90d': '90 Days',
'keyUsage.dateRangeCustom': 'Custom',
'keyUsage.apply': 'Apply',
'keyUsage.used': 'Used',
'keyUsage.detailInfo': 'Detail Information',
'keyUsage.tokenStats': 'Token Statistics',
'keyUsage.dailyDetail': 'Daily Detail',
'keyUsage.date': 'Date',
'keyUsage.requests': 'Requests',
'keyUsage.inputTokens': 'Input Tokens',
'keyUsage.outputTokens': 'Output Tokens',
'keyUsage.cacheReadTokens': 'Cache Read',
'keyUsage.cacheWriteTokens': 'Cache Write',
'keyUsage.cost': 'Cost',
'keyUsage.quotaMode': 'Key Quota Mode',
'keyUsage.walletBalance': 'Wallet Balance',
'keyUsage.totalQuota': 'Total Quota',
'keyUsage.limit5h': '5-Hour Limit',
'keyUsage.limitDaily': 'Daily Limit',
'keyUsage.limit7d': '7-Day Limit',
'keyUsage.limitWeekly': 'Weekly Limit',
'keyUsage.limitMonthly': 'Monthly Limit',
'keyUsage.remainingQuota': 'Remaining Quota',
'keyUsage.usedQuota': 'Used Quota',
'keyUsage.subscriptionType': 'Subscription Type',
'keyUsage.todayRequests': 'Today Requests',
'keyUsage.todayInputTokens': 'Today Input',
'keyUsage.todayOutputTokens': 'Today Output',
'keyUsage.todayTokens': 'Today Tokens',
'keyUsage.todayCacheCreation': 'Today Cache Creation',
'keyUsage.todayCacheRead': 'Today Cache Read',
'keyUsage.todayCost': 'Today Cost',
'keyUsage.rpmTpm': 'RPM / TPM',
'keyUsage.totalRequests': 'Total Requests',
'keyUsage.totalInputTokens': 'Total Input',
'keyUsage.totalOutputTokens': 'Total Output',
'keyUsage.totalTokensLabel': 'Total Tokens',
'keyUsage.totalCacheCreation': 'Total Cache Creation',
'keyUsage.totalCacheRead': 'Total Cache Read',
'keyUsage.totalCost': 'Total Cost',
'keyUsage.avgDuration': 'Avg Duration',
'keyUsage.querySuccess': 'Query successful',
'keyUsage.queryFailed': 'Query failed',
'keyUsage.queryFailedRetry': 'Query failed, please try again later',
'home.viewDocs': 'Docs',
'home.switchToLight': 'Light',
'home.switchToDark': 'Dark',
'home.footer.allRightsReserved': 'All rights reserved.',
}
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
locale: { value: 'en' },
}),
}
})
vi.mock('@/stores', () => ({
useAppStore: () => ({
cachedPublicSettings: null,
siteName: 'Sub2API',
siteLogo: '',
docUrl: '',
publicSettingsLoaded: true,
fetchPublicSettings,
showInfo,
showSuccess,
showError,
}),
}))
describe('KeyUsageView daily detail', () => {
beforeEach(() => {
showInfo.mockReset()
showSuccess.mockReset()
showError.mockReset()
fetchPublicSettings.mockReset()
localStorage.clear()
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({ matches: false }),
})
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0))
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
mode: 'quota_limited',
isValid: true,
status: 'active',
quota: {
limit: 10,
used: 1,
remaining: 9,
unit: 'USD',
},
usage: {
today: {
requests: 1,
input_tokens: 10,
output_tokens: 20,
cache_creation_tokens: 0,
cache_read_tokens: 0,
total_tokens: 30,
actual_cost: 0.01,
},
total: {
requests: 12,
input_tokens: 100,
output_tokens: 200,
cache_creation_tokens: 10,
cache_read_tokens: 30,
total_tokens: 340,
actual_cost: 0.12,
},
rpm: 0,
tpm: 0,
},
daily_usage: [
{
date: '2026-05-19',
requests: 12,
input_tokens: 100,
output_tokens: 200,
cache_read_tokens: 30,
cache_write_tokens: 10,
total_tokens: 340,
cost: 0.15,
actual_cost: 0.12,
},
],
}),
}))
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders daily usage detail rows after a successful query', async () => {
const wrapper = mount(KeyUsageView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
LocaleSwitcher: true,
Icon: true,
},
},
})
await wrapper.find('input').setValue('sk-test-key')
await wrapper.find('input').trigger('keydown.enter')
await flushPromises()
await nextTick()
const fetchMock = vi.mocked(fetch)
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/v1/usage?'),
expect.objectContaining({
headers: { Authorization: 'Bearer sk-test-key' },
})
)
expect(String(fetchMock.mock.calls[0][0])).toContain('days=30')
const text = wrapper.text()
expect(text).toContain('Daily Detail')
expect(text).toContain('Date')
expect(text).toContain('Cache Read')
expect(text).toContain('Cache Write')
expect(text).toContain('2026-05-19')
expect(text).toContain('12')
expect(text).toContain('100')
expect(text).toContain('200')
expect(text).toContain('30')
expect(text).toContain('10')
expect(text).toContain('$0.12')
wrapper.unmount()
})
})