mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 账户时间线详情页与接口完善
- 后端新增 /admin/accounts/:accountId/usage-records 接口,支持按账户聚合多 Key 记录并分页筛选、汇总统计 - 修复 API Key 时间线账户筛选跳过已删除账号,补充账户/Key 辅助解析 - 前端新增 AccountUsageRecordsView、路由及账户列表“时间线”入口,支持模型/API Key 筛选与 CSV 导出 - 补装 prettier-plugin-tailwindcss 并完成相关文件格式化
This commit is contained in:
@@ -13,6 +13,7 @@ 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 AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
|
||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||
@@ -110,6 +111,18 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/accounts/:accountId/usage-records',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'AccountUsageRecords',
|
||||
component: AccountUsageRecordsView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/tutorial',
|
||||
component: MainLayout,
|
||||
|
||||
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
@@ -0,0 +1,574 @@
|
||||
<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">
|
||||
账户请求详情时间线
|
||||
</p>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ accountDisplayName }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ accountId }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">渠道:{{ platformDisplayName }}</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.apiKeyId"
|
||||
class="w-[220px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有 API Key"
|
||||
>
|
||||
<el-option
|
||||
v-for="apiKey in availableApiKeys"
|
||||
:key="apiKey.id"
|
||||
:label="apiKey.name || apiKey.id"
|
||||
:value="apiKey.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"
|
||||
>
|
||||
API Key
|
||||
</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.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
ID: {{ record.apiKeyId }}
|
||||
</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.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
ID: {{ record.apiKeyId }} · {{ formatDate(record.timestamp) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
渠道:{{ platformDisplayName }}
|
||||
</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 accountId = computed(() => route.params.accountId)
|
||||
const platform = computed(() => route.query.platform)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const records = ref([])
|
||||
const availableModels = ref([])
|
||||
const availableApiKeys = ref([])
|
||||
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
totalRecords: 0
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
dateRange: null,
|
||||
model: '',
|
||||
apiKeyId: '',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
|
||||
const summary = reactive({
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
avgCost: 0
|
||||
})
|
||||
|
||||
const accountInfo = reactive({
|
||||
id: accountId.value,
|
||||
name: '',
|
||||
platform: platform.value || ''
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const activeRecord = ref(null)
|
||||
|
||||
const accountDisplayName = computed(() => accountInfo.name || accountInfo.id || accountId.value)
|
||||
const platformDisplayName = computed(() => {
|
||||
const map = {
|
||||
claude: 'Claude官方',
|
||||
'claude-console': 'Claude Console',
|
||||
ccr: 'Claude Console Relay',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
droid: 'Droid',
|
||||
unknown: '未知渠道'
|
||||
}
|
||||
const key = accountInfo.platform || platform.value || 'unknown'
|
||||
return map[key] || '未知渠道'
|
||||
})
|
||||
|
||||
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.apiKeyId) params.apiKeyId = filters.apiKeyId
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||
}
|
||||
if (platform.value) {
|
||||
params.platform = platform.value
|
||||
}
|
||||
|
||||
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.apiKeyId !== undefined) filters.apiKeyId = filterEcho.apiKeyId || ''
|
||||
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
|
||||
|
||||
accountInfo.id = data.accountInfo?.id || accountId.value
|
||||
accountInfo.name = data.accountInfo?.name || ''
|
||||
accountInfo.platform = data.accountInfo?.platform || platform.value || ''
|
||||
|
||||
availableModels.value = data.availableFilters?.models || []
|
||||
availableApiKeys.value = data.availableFilters?.apiKeys || []
|
||||
}
|
||||
|
||||
const fetchRecords = async (page = pagination.currentPage) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/accounts/${accountId.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.apiKeyId = ''
|
||||
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('/accounts')
|
||||
}
|
||||
|
||||
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/accounts/${accountId.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 = [
|
||||
'时间',
|
||||
'API Key',
|
||||
'模型',
|
||||
'输入Token',
|
||||
'输出Token',
|
||||
'缓存创建Token',
|
||||
'缓存读取Token',
|
||||
'总Token',
|
||||
'费用'
|
||||
]
|
||||
|
||||
const csvRows = [headers.join(',')]
|
||||
aggregated.forEach((record) => {
|
||||
const row = [
|
||||
formatDate(record.timestamp),
|
||||
record.apiKeyName || record.apiKeyId || '',
|
||||
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 = `account-${accountId.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.apiKeyId, filters.sortOrder],
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.dateRange,
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
</script>
|
||||
@@ -1199,6 +1199,15 @@
|
||||
<i class="fas fa-chart-line" />
|
||||
<span class="ml-1">详情</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canViewUsage(account)"
|
||||
class="rounded bg-purple-100 px-2.5 py-1 text-xs font-medium text-purple-700 transition-colors hover:bg-purple-200"
|
||||
title="请求时间线"
|
||||
@click="viewAccountTimeline(account)"
|
||||
>
|
||||
<i class="fas fa-clock" />
|
||||
<span class="ml-1">时间线</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canTestAccount(account)"
|
||||
class="rounded bg-cyan-100 px-2.5 py-1 text-xs font-medium text-cyan-700 transition-colors hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"
|
||||
@@ -1668,6 +1677,14 @@
|
||||
<i class="fas fa-chart-line" />
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
v-if="canViewUsage(account)"
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-purple-50 px-3 py-2 text-xs text-purple-600 transition-colors hover:bg-purple-100"
|
||||
@click="viewAccountTimeline(account)"
|
||||
>
|
||||
<i class="fas fa-clock" />
|
||||
时间线
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canTestAccount(account)"
|
||||
@@ -1855,6 +1872,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -1869,6 +1887,7 @@ import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
|
||||
// 使用确认弹窗
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
const router = useRouter()
|
||||
|
||||
// 数据状态
|
||||
const accounts = ref([])
|
||||
@@ -2100,6 +2119,13 @@ const getAccountActions = (account) => {
|
||||
color: 'indigo',
|
||||
handler: () => openAccountUsageModal(account)
|
||||
})
|
||||
actions.push({
|
||||
key: 'timeline',
|
||||
label: '请求时间线',
|
||||
icon: 'fa-clock',
|
||||
color: 'purple',
|
||||
handler: () => viewAccountTimeline(account)
|
||||
})
|
||||
}
|
||||
|
||||
// 测试账户
|
||||
@@ -2160,6 +2186,13 @@ const openAccountUsageModal = async (account) => {
|
||||
}
|
||||
}
|
||||
|
||||
const viewAccountTimeline = (account) => {
|
||||
router.push({
|
||||
path: `/accounts/${account.id}/usage-records`,
|
||||
query: { platform: account.platform || account.accountType }
|
||||
})
|
||||
}
|
||||
|
||||
const closeAccountUsageModal = () => {
|
||||
showAccountUsageModal.value = false
|
||||
accountUsageLoading.value = false
|
||||
|
||||
Reference in New Issue
Block a user