feat: add API key usage timeline API and admin UI

This commit is contained in:
atoz03
2025-12-04 14:41:38 +08:00
parent b63f2f78fc
commit 92b30e1924
6 changed files with 1107 additions and 41 deletions

View 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>

View File

@@ -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,

View 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>

View File

@@ -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 组件中