mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:40:25 +00:00
feat: add API key usage timeline API and admin UI
This commit is contained in:
208
web/admin-spa/src/components/apikeys/RecordDetailModal.vue
Normal file
208
web/admin-spa/src/components/apikeys/RecordDetailModal.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
class="record-detail-modal"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="show"
|
||||
:show-close="false"
|
||||
width="720px"
|
||||
@close="emitClose"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
请求详情
|
||||
</p>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ record?.model || '未知模型' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭"
|
||||
class="rounded-full p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
@click="emitClose"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">基本信息</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">时间</span>
|
||||
<span class="font-medium">{{ formattedTime }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">模型</span>
|
||||
<span class="font-medium">{{ record?.model || '未知模型' }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">账户</span>
|
||||
<span class="font-medium">{{ record?.accountName || '未知账户' }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">渠道</span>
|
||||
<span class="font-medium">{{ record?.accountTypeName || '未知渠道' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Token 使用</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">输入 Token</span>
|
||||
<span class="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{{ formatNumber(record?.inputTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">输出 Token</span>
|
||||
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||
{{ formatNumber(record?.outputTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">缓存创建</span>
|
||||
<span class="font-semibold text-purple-600 dark:text-purple-400">
|
||||
{{ formatNumber(record?.cacheCreateTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">缓存读取</span>
|
||||
<span class="font-semibold text-orange-600 dark:text-orange-400">
|
||||
{{ formatNumber(record?.cacheReadTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">总计</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(record?.totalTokens) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">费用详情</h4>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">输入费用</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.input }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">输出费用</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.output }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">缓存创建</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.cacheCreate }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">缓存读取</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formattedCosts.cacheRead }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">总费用</span>
|
||||
<div class="text-base font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ record?.costFormatted || formattedCosts.total }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<el-button type="primary" @click="emitClose">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
record: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const emitClose = () => emit('close')
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!props.record?.timestamp) return '未知时间'
|
||||
return dayjs(props.record.timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||
})
|
||||
|
||||
const formattedCosts = computed(() => {
|
||||
const breakdown = props.record?.costBreakdown || {}
|
||||
const formatValue = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||
return `$${num.toFixed(6)}`
|
||||
}
|
||||
|
||||
return {
|
||||
input: formatValue(breakdown.input),
|
||||
output: formatValue(breakdown.output),
|
||||
cacheCreate: formatValue(breakdown.cacheCreate),
|
||||
cacheRead: formatValue(breakdown.cacheRead),
|
||||
total: formatValue(breakdown.total)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.record-detail-modal :deep(.el-dialog__header) {
|
||||
margin: 0;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.record-detail-modal :deep(.el-dialog__body) {
|
||||
padding: 12px 16px 4px;
|
||||
}
|
||||
|
||||
.record-detail-modal :deep(.el-dialog__footer) {
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ const UserManagementView = () => import('@/views/UserManagementView.vue')
|
||||
const MainLayout = () => import('@/components/layout/MainLayout.vue')
|
||||
const DashboardView = () => import('@/views/DashboardView.vue')
|
||||
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
||||
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
|
||||
const AccountsView = () => import('@/views/AccountsView.vue')
|
||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
@@ -85,6 +86,18 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/api-keys/:keyId/usage-records',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ApiKeyUsageRecords',
|
||||
component: ApiKeyUsageRecordsView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
component: MainLayout,
|
||||
|
||||
549
web/admin-spa/src/views/ApiKeyUsageRecordsView.vue
Normal file
549
web/admin-spa/src/views/ApiKeyUsageRecordsView.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4 lg:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
@click="goBack"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
API Key 请求详情时间线
|
||||
</p>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ apiKeyDisplayName }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ keyId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock text-blue-500" />
|
||||
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
|
||||
<span v-else>显示近 5000 条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalRequests) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总 Token</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalTokens) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
|
||||
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ formatCost(summary.totalCost) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/次</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatCost(summary.avgCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
class="max-w-[320px]"
|
||||
clearable
|
||||
end-placeholder="结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
type="datetimerange"
|
||||
unlink-panels
|
||||
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="filters.model"
|
||||
class="w-[180px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="modelOption in availableModels"
|
||||
:key="modelOption"
|
||||
:label="modelOption"
|
||||
:value="modelOption"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="filters.accountId"
|
||||
class="w-[220px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有账户"
|
||||
>
|
||||
<el-option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.id"
|
||||
:label="`${account.name}(${account.accountTypeName})`"
|
||||
:value="account.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
|
||||
<el-option label="时间降序" value="desc" />
|
||||
<el-option label="时间升序" value="asc" />
|
||||
</el-select>
|
||||
|
||||
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
|
||||
<el-button :loading="exporting" type="primary" @click="exportCsv">
|
||||
<i class="fas fa-file-export mr-2" /> 导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="records.length === 0"
|
||||
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-inbox text-2xl" />
|
||||
<p>暂无记录</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
时间
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
账户
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
模型
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输入
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输出
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
缓存(创/读)
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
总 Token
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
费用
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<tr v-for="record in records" :key="record.timestamp + record.model">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">{{ record.accountName || '未知账户' }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ record.accountTypeName || '未知渠道' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ record.model }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ formatNumber(record.inputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
{{ formatNumber(record.outputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatNumber(record.totalTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 md:hidden">
|
||||
<div
|
||||
v-for="record in records"
|
||||
:key="record.timestamp + record.model"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ record.accountName || '未知账户' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>模型:{{ record.model }}</div>
|
||||
<div>总 Token:{{ formatNumber(record.totalTokens) }}</div>
|
||||
<div>输入:{{ formatNumber(record.inputTokens) }}</div>
|
||||
<div>输出:{{ formatNumber(record.outputTokens) }}</div>
|
||||
<div>
|
||||
缓存创/读:{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</div>
|
||||
<div class="text-yellow-600 dark:text-yellow-400">
|
||||
费用:{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 pb-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
共 {{ pagination.totalRecords }} 条记录
|
||||
</div>
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="pagination.currentPage"
|
||||
layout="prev, pager, next, sizes"
|
||||
:page-size="pagination.pageSize"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
:total="pagination.totalRecords"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const keyId = computed(() => route.params.keyId)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const records = ref([])
|
||||
const availableModels = ref([])
|
||||
const availableAccounts = ref([])
|
||||
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
totalRecords: 0
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
dateRange: null,
|
||||
model: '',
|
||||
accountId: '',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
|
||||
const summary = reactive({
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
avgCost: 0
|
||||
})
|
||||
|
||||
const apiKeyInfo = reactive({
|
||||
id: keyId.value,
|
||||
name: ''
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const activeRecord = ref(null)
|
||||
|
||||
const apiKeyDisplayName = computed(() => apiKeyInfo.name || apiKeyInfo.id || keyId.value)
|
||||
|
||||
const dateRangeHint = computed(() => {
|
||||
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
|
||||
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatCost = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||
return `$${num.toFixed(6)}`
|
||||
}
|
||||
|
||||
const buildParams = (page) => {
|
||||
const params = {
|
||||
page,
|
||||
pageSize: pagination.pageSize,
|
||||
sortOrder: filters.sortOrder
|
||||
}
|
||||
|
||||
if (filters.model) params.model = filters.model
|
||||
if (filters.accountId) params.accountId = filters.accountId
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const syncResponseState = (data) => {
|
||||
records.value = data.records || []
|
||||
|
||||
const pageInfo = data.pagination || {}
|
||||
pagination.currentPage = pageInfo.currentPage || 1
|
||||
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
|
||||
pagination.totalRecords = pageInfo.totalRecords || 0
|
||||
|
||||
const filterEcho = data.filters || {}
|
||||
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
|
||||
if (filterEcho.accountId !== undefined) filters.accountId = filterEcho.accountId || ''
|
||||
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
|
||||
if (filterEcho.startDate && filterEcho.endDate) {
|
||||
const nextRange = [filterEcho.startDate, filterEcho.endDate]
|
||||
const currentRange = filters.dateRange || []
|
||||
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
|
||||
filters.dateRange = nextRange
|
||||
}
|
||||
}
|
||||
|
||||
const summaryData = data.summary || {}
|
||||
summary.totalRequests = summaryData.totalRequests || 0
|
||||
summary.totalTokens = summaryData.totalTokens || 0
|
||||
summary.totalCost = summaryData.totalCost || 0
|
||||
summary.avgCost = summaryData.avgCost || 0
|
||||
|
||||
apiKeyInfo.id = data.apiKeyInfo?.id || keyId.value
|
||||
apiKeyInfo.name = data.apiKeyInfo?.name || ''
|
||||
|
||||
availableModels.value = data.availableFilters?.models || []
|
||||
availableAccounts.value = data.availableFilters?.accounts || []
|
||||
}
|
||||
|
||||
const fetchRecords = async (page = pagination.currentPage) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||
params: buildParams(page)
|
||||
})
|
||||
syncResponseState(response.data || {})
|
||||
} catch (error) {
|
||||
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
pagination.currentPage = page
|
||||
fetchRecords(page)
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.model = ''
|
||||
filters.accountId = ''
|
||||
filters.dateRange = null
|
||||
filters.sortOrder = 'desc'
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const openDetail = (record) => {
|
||||
activeRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const closeDetail = () => {
|
||||
detailVisible.value = false
|
||||
activeRecord.value = null
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/api-keys')
|
||||
}
|
||||
|
||||
const exportCsv = async () => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const aggregated = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||
|
||||
while (page <= totalPages && page <= maxPages) {
|
||||
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||
params: { ...buildParams(page), pageSize: 200 }
|
||||
})
|
||||
const payload = response.data || {}
|
||||
aggregated.push(...(payload.records || []))
|
||||
totalPages = payload.pagination?.totalPages || 1
|
||||
page += 1
|
||||
}
|
||||
|
||||
if (aggregated.length === 0) {
|
||||
showToast('没有可导出的记录', 'info')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'时间',
|
||||
'账户',
|
||||
'渠道',
|
||||
'模型',
|
||||
'输入Token',
|
||||
'输出Token',
|
||||
'缓存创建Token',
|
||||
'缓存读取Token',
|
||||
'总Token',
|
||||
'费用'
|
||||
]
|
||||
|
||||
const csvRows = [headers.join(',')]
|
||||
aggregated.forEach((record) => {
|
||||
const row = [
|
||||
formatDate(record.timestamp),
|
||||
record.accountName || '',
|
||||
record.accountTypeName || '',
|
||||
record.model || '',
|
||||
record.inputTokens || 0,
|
||||
record.outputTokens || 0,
|
||||
record.cacheCreateTokens || 0,
|
||||
record.cacheReadTokens || 0,
|
||||
record.totalTokens || 0,
|
||||
record.costFormatted || formatCost(record.cost)
|
||||
]
|
||||
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
})
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `api-key-${keyId.value}-usage-records.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showToast('导出 CSV 成功', 'success')
|
||||
} catch (error) {
|
||||
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [filters.model, filters.accountId, filters.sortOrder],
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.dateRange,
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
</script>
|
||||
@@ -2084,18 +2084,12 @@
|
||||
@close="closeExpiryEdit"
|
||||
@save="handleSaveExpiry"
|
||||
/>
|
||||
|
||||
<!-- 使用详情弹窗 -->
|
||||
<UsageDetailModal
|
||||
:api-key="selectedApiKeyForDetail || {}"
|
||||
:show="showUsageDetailModal"
|
||||
@close="showUsageDetailModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
@@ -2108,12 +2102,12 @@ import NewApiKeyModal from '@/components/apikeys/NewApiKeyModal.vue'
|
||||
import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
|
||||
import BatchEditApiKeyModal from '@/components/apikeys/BatchEditApiKeyModal.vue'
|
||||
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
|
||||
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
|
||||
import LimitProgressBar from '@/components/apikeys/LimitProgressBar.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
|
||||
// 响应式数据
|
||||
const router = useRouter()
|
||||
const clientsStore = useClientsStore()
|
||||
const authStore = useAuthStore()
|
||||
const apiKeys = ref([])
|
||||
@@ -2211,8 +2205,6 @@ const accountsLoading = ref(false)
|
||||
const accountsLoaded = ref(false)
|
||||
const editingExpiryKey = ref(null)
|
||||
const expiryEditModalRef = ref(null)
|
||||
const showUsageDetailModal = ref(false)
|
||||
const selectedApiKeyForDetail = ref(null)
|
||||
|
||||
// 标签相关
|
||||
const selectedTagFilter = ref('')
|
||||
@@ -4193,37 +4185,7 @@ const formatWindowTime = (seconds) => {
|
||||
|
||||
// 显示使用详情
|
||||
const showUsageDetails = (apiKey) => {
|
||||
// 获取异步加载的统计数据
|
||||
const cachedStats = getCachedStats(apiKey.id)
|
||||
|
||||
// 合并异步统计数据到 apiKey 对象
|
||||
const enrichedApiKey = {
|
||||
...apiKey,
|
||||
// 合并实时限制数据
|
||||
dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0,
|
||||
currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0,
|
||||
windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds,
|
||||
windowStartTime: cachedStats?.windowStartTime ?? apiKey.windowStartTime ?? null,
|
||||
windowEndTime: cachedStats?.windowEndTime ?? apiKey.windowEndTime ?? null,
|
||||
// 合并 usage 数据(用于详情弹窗中的统计卡片)
|
||||
usage: {
|
||||
...apiKey.usage,
|
||||
total: {
|
||||
...apiKey.usage?.total,
|
||||
requests: cachedStats?.requests ?? apiKey.usage?.total?.requests ?? 0,
|
||||
tokens: cachedStats?.tokens ?? apiKey.usage?.total?.tokens ?? 0,
|
||||
cost: cachedStats?.allTimeCost ?? apiKey.usage?.total?.cost ?? 0,
|
||||
inputTokens: cachedStats?.inputTokens ?? apiKey.usage?.total?.inputTokens ?? 0,
|
||||
outputTokens: cachedStats?.outputTokens ?? apiKey.usage?.total?.outputTokens ?? 0,
|
||||
cacheCreateTokens:
|
||||
cachedStats?.cacheCreateTokens ?? apiKey.usage?.total?.cacheCreateTokens ?? 0,
|
||||
cacheReadTokens: cachedStats?.cacheReadTokens ?? apiKey.usage?.total?.cacheReadTokens ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedApiKeyForDetail.value = enrichedApiKey
|
||||
showUsageDetailModal.value = true
|
||||
router.push(`/api-keys/${apiKey.id}/usage-records`)
|
||||
}
|
||||
|
||||
// 格式化时间(秒转换为可读格式) - 已移到 WindowLimitBar 组件中
|
||||
|
||||
Reference in New Issue
Block a user