Merge pull request #766 from atoz03/feature/account-detail-timeline [skip ci]

feat(account): enhance detail timeline & remove redundant entries
This commit is contained in:
Wesley Liddick
2025-12-05 21:43:14 -05:00
committed by GitHub
8 changed files with 1096 additions and 37 deletions

80
package-lock.json generated
View File

@@ -44,6 +44,7 @@
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.2",
"supertest": "^6.3.3" "supertest": "^6.3.3"
}, },
"engines": { "engines": {
@@ -7605,6 +7606,85 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.19"
},
"peerDependencies": {
"@ianvs/prettier-plugin-sort-imports": "*",
"@prettier/plugin-hermes": "*",
"@prettier/plugin-oxc": "*",
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
"prettier-plugin-jsdoc": "*",
"prettier-plugin-marko": "*",
"prettier-plugin-multiline-arrays": "*",
"prettier-plugin-organize-attributes": "*",
"prettier-plugin-organize-imports": "*",
"prettier-plugin-sort-imports": "*",
"prettier-plugin-svelte": "*"
},
"peerDependenciesMeta": {
"@ianvs/prettier-plugin-sort-imports": {
"optional": true
},
"@prettier/plugin-hermes": {
"optional": true
},
"@prettier/plugin-oxc": {
"optional": true
},
"@prettier/plugin-pug": {
"optional": true
},
"@shopify/prettier-plugin-liquid": {
"optional": true
},
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@zackad/prettier-plugin-twig": {
"optional": true
},
"prettier-plugin-astro": {
"optional": true
},
"prettier-plugin-css-order": {
"optional": true
},
"prettier-plugin-jsdoc": {
"optional": true
},
"prettier-plugin-marko": {
"optional": true
},
"prettier-plugin-multiline-arrays": {
"optional": true
},
"prettier-plugin-organize-attributes": {
"optional": true
},
"prettier-plugin-organize-imports": {
"optional": true
},
"prettier-plugin-sort-imports": {
"optional": true
},
"prettier-plugin-svelte": {
"optional": true
}
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",

View File

@@ -83,6 +83,7 @@
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.2",
"supertest": "^6.3.3" "supertest": "^6.3.3"
}, },
"engines": { "engines": {

View File

@@ -16,6 +16,65 @@ const pricingService = require('../../services/pricingService')
const router = express.Router() const router = express.Router()
const accountTypeNames = {
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 resolveAccountByPlatform = async (accountId, platform) => {
const serviceMap = {
claude: claudeAccountService,
'claude-console': claudeConsoleAccountService,
gemini: geminiAccountService,
'gemini-api': geminiApiAccountService,
openai: openaiAccountService,
'openai-responses': openaiResponsesAccountService,
droid: droidAccountService,
ccr: ccrAccountService
}
if (platform && serviceMap[platform]) {
try {
const account = await serviceMap[platform].getAccount(accountId)
if (account) {
return { ...account, platform }
}
} catch (error) {
logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`)
}
}
for (const [platformName, service] of Object.entries(serviceMap)) {
try {
const account = await service.getAccount(accountId)
if (account) {
return { ...account, platform: platformName }
}
} catch (error) {
logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`)
}
}
return null
}
const getApiKeyName = async (keyId) => {
try {
const keyData = await redis.getApiKey(keyId)
return keyData?.name || keyData?.label || keyId
} catch (error) {
logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`)
return keyId
}
}
// 📊 账户使用统计 // 📊 账户使用统计
// 获取所有账户的使用统计 // 获取所有账户的使用统计
@@ -1861,18 +1920,6 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
const rawRecords = await redis.getUsageRecords(keyId, 5000) const rawRecords = await redis.getUsageRecords(keyId, 5000)
const accountTypeNames = {
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 accountServices = [ const accountServices = [
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) }, { type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) }, { type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
@@ -1895,10 +1942,15 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
return accountCache.get(cacheKey) return accountCache.get(cacheKey)
} }
const servicesToTry = type let servicesToTry = type
? accountServices.filter((svc) => svc.type === type) ? accountServices.filter((svc) => svc.type === type)
: accountServices : accountServices
// 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号
if (!servicesToTry.length) {
servicesToTry = accountServices
}
for (const service of servicesToTry) { for (const service of servicesToTry) {
try { try {
const account = await service.getter(id) const account = await service.getter(id)
@@ -2008,12 +2060,14 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
} }
if (record.accountId) { if (record.accountId) {
const key = `${record.accountId}:${record.accountType || 'unknown'}` const normalizedType = record.accountType || 'unknown'
if (!accountOptionMap.has(key)) { if (!accountOptionMap.has(record.accountId)) {
accountOptionMap.set(key, { accountOptionMap.set(record.accountId, {
id: record.accountId, id: record.accountId,
accountType: record.accountType || 'unknown' accountTypes: new Set([normalizedType])
}) })
} else {
accountOptionMap.get(record.accountId).accountTypes.add(normalizedType)
} }
} }
@@ -2087,13 +2141,33 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
const accountOptions = [] const accountOptions = []
for (const option of accountOptionMap.values()) { for (const option of accountOptionMap.values()) {
const info = await resolveAccountInfo(option.id, option.accountType) const types = Array.from(option.accountTypes || [])
const resolvedType = info?.type || option.accountType || 'unknown'
// 优先按历史出现的 accountType 解析,若失败则回退全量解析
let resolvedInfo = null
for (const type of types) {
resolvedInfo = await resolveAccountInfo(option.id, type)
if (resolvedInfo && resolvedInfo.name) {
break
}
}
if (!resolvedInfo) {
resolvedInfo = await resolveAccountInfo(option.id)
}
const chosenType = resolvedInfo?.type || types[0] || 'unknown'
const chosenTypeName = accountTypeNames[chosenType] || '未知渠道'
if (!resolvedInfo) {
logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`)
}
accountOptions.push({ accountOptions.push({
id: option.id, id: option.id,
name: info?.name || option.id, name: resolvedInfo?.name || option.id,
accountType: resolvedType, accountType: chosenType,
accountTypeName: accountTypeNames[resolvedType] || '未知渠道' accountTypeName: chosenTypeName,
rawTypes: types
}) })
} }
@@ -2146,4 +2220,287 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
} }
}) })
// 获取账户的请求记录时间线
router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => {
try {
const { accountId } = req.params
const {
platform,
page = 1,
pageSize = 50,
startDate,
endDate,
model,
apiKeyId,
sortOrder = 'desc'
} = req.query
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
const startTime = startDate ? new Date(startDate) : null
const endTime = endDate ? new Date(endDate) : null
if (
(startDate && Number.isNaN(startTime?.getTime())) ||
(endDate && Number.isNaN(endTime?.getTime()))
) {
return res.status(400).json({ success: false, error: 'Invalid date range' })
}
if (startTime && endTime && startTime > endTime) {
return res
.status(400)
.json({ success: false, error: 'Start date must be before or equal to end date' })
}
const accountInfo = await resolveAccountByPlatform(accountId, platform)
if (!accountInfo) {
return res.status(404).json({ success: false, error: 'Account not found' })
}
const allApiKeys = await apiKeyService.getAllApiKeys(true)
const apiKeyNameCache = new Map(
allApiKeys.map((key) => [key.id, key.name || key.label || key.id])
)
let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys
if (apiKeyId && keysToUse.length === 0) {
keysToUse = [{ id: apiKeyId }]
}
const toUsageObject = (record) => ({
input_tokens: record.inputTokens || 0,
output_tokens: record.outputTokens || 0,
cache_creation_input_tokens: record.cacheCreateTokens || 0,
cache_read_input_tokens: record.cacheReadTokens || 0,
cache_creation: record.cacheCreation || record.cache_creation || null
})
const withinRange = (record) => {
if (!record.timestamp) {
return false
}
const ts = new Date(record.timestamp)
if (Number.isNaN(ts.getTime())) {
return false
}
if (startTime && ts < startTime) {
return false
}
if (endTime && ts > endTime) {
return false
}
return true
}
const filteredRecords = []
const modelSet = new Set()
const apiKeyOptionMap = new Map()
let earliestTimestamp = null
let latestTimestamp = null
const batchSize = 10
for (let i = 0; i < keysToUse.length; i += batchSize) {
const batch = keysToUse.slice(i, i + batchSize)
const batchResults = await Promise.all(
batch.map(async (key) => {
try {
const records = await redis.getUsageRecords(key.id, 5000)
return { keyId: key.id, records: records || [] }
} catch (error) {
logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`)
return { keyId: key.id, records: [] }
}
})
)
for (const { keyId, records } of batchResults) {
const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId))
for (const record of records) {
if (record.accountId !== accountId) {
continue
}
if (!withinRange(record)) {
continue
}
if (model && record.model !== model) {
continue
}
const accountType = record.accountType || accountInfo.platform || 'unknown'
const normalizedModel = record.model || 'unknown'
modelSet.add(normalizedModel)
apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName })
if (record.timestamp) {
const ts = new Date(record.timestamp)
if (!Number.isNaN(ts.getTime())) {
if (!earliestTimestamp || ts < earliestTimestamp) {
earliestTimestamp = ts
}
if (!latestTimestamp || ts > latestTimestamp) {
latestTimestamp = ts
}
}
}
filteredRecords.push({
...record,
model: normalizedModel,
accountType,
apiKeyId: keyId,
apiKeyName
})
}
}
}
filteredRecords.sort((a, b) => {
const aTime = new Date(a.timestamp).getTime()
const bTime = new Date(b.timestamp).getTime()
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
return 0
}
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
})
const summary = {
totalRequests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0
}
for (const record of filteredRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
summary.totalRequests += 1
summary.inputTokens += usage.input_tokens
summary.outputTokens += usage.output_tokens
summary.cacheCreateTokens += usage.cache_creation_input_tokens
summary.cacheReadTokens += usage.cache_read_input_tokens
summary.totalTokens += totalTokens
summary.totalCost += computedCost
}
const totalRecords = filteredRecords.length
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
const startIndex = (safePage - 1) * pageSizeNumber
const pageRecords =
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
const enrichedRecords = []
for (const record of pageRecords) {
const usage = toUsageObject(record)
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
const computedCost =
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
const totalTokens =
record.totalTokens ||
usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
enrichedRecords.push({
timestamp: record.timestamp,
model: record.model || 'unknown',
apiKeyId: record.apiKeyId,
apiKeyName: record.apiKeyName,
accountId,
accountName: accountInfo.name || accountInfo.email || accountId,
accountType: record.accountType,
accountTypeName: accountTypeNames[record.accountType] || '未知渠道',
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
ephemeral5mTokens: record.ephemeral5mTokens || 0,
ephemeral1hTokens: record.ephemeral1hTokens || 0,
totalTokens,
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
cost: Number(computedCost.toFixed(6)),
costFormatted:
record.costFormatted ||
costData?.formatted?.total ||
CostCalculator.formatCost(computedCost),
costBreakdown: record.costBreakdown || {
input: costData?.costs?.input || 0,
output: costData?.costs?.output || 0,
cacheCreate: costData?.costs?.cacheWrite || 0,
cacheRead: costData?.costs?.cacheRead || 0,
total: costData?.costs?.total || computedCost
},
responseTime: record.responseTime || null
})
}
return res.json({
success: true,
data: {
records: enrichedRecords,
pagination: {
currentPage: safePage,
pageSize: pageSizeNumber,
totalRecords,
totalPages,
hasNextPage: totalPages > 0 && safePage < totalPages,
hasPreviousPage: totalPages > 0 && safePage > 1
},
filters: {
startDate: startTime ? startTime.toISOString() : null,
endDate: endTime ? endTime.toISOString() : null,
model: model || null,
apiKeyId: apiKeyId || null,
platform: accountInfo.platform,
sortOrder: normalizedSortOrder
},
accountInfo: {
id: accountId,
name: accountInfo.name || accountInfo.email || accountId,
platform: accountInfo.platform || platform || 'unknown',
status: accountInfo.status ?? accountInfo.isActive ?? null
},
summary: {
...summary,
totalCost: Number(summary.totalCost.toFixed(6)),
avgCost:
summary.totalRequests > 0
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
: 0
},
availableFilters: {
models: Array.from(modelSet),
apiKeys: Array.from(apiKeyOptionMap.values()),
dateRange: {
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
latest: latestTimestamp ? latestTimestamp.toISOString() : null
}
}
}
})
} catch (error) {
logger.error('❌ Failed to get account usage records:', error)
return res
.status(500)
.json({ error: 'Failed to get account usage records', message: error.message })
}
})
module.exports = router module.exports = router

View File

@@ -44,6 +44,13 @@
</p> </p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
<button
class="flex items-center gap-2 rounded-full bg-purple-100 px-3 py-2 text-xs font-semibold text-purple-700 transition hover:bg-purple-200 dark:bg-purple-500/10 dark:text-purple-200 dark:hover:bg-purple-500/20"
@click="goTimeline"
>
<i class="fas fa-clock" /> 请求时间线
</button>
<button <button
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200" class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
@click="handleClose" @click="handleClose"
@@ -51,6 +58,7 @@
<i class="fas fa-times" /> <i class="fas fa-times" />
</button> </button>
</div> </div>
</div>
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="flex-1 overflow-y-auto px-5 py-4 sm:px-6"> <div class="flex-1 overflow-y-auto px-5 py-4 sm:px-6">
@@ -325,6 +333,7 @@
<script setup> <script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import Chart from 'chart.js/auto' import Chart from 'chart.js/auto'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
@@ -343,6 +352,7 @@ const emit = defineEmits(['close'])
const themeStore = useThemeStore() const themeStore = useThemeStore()
const { isDarkMode } = storeToRefs(themeStore) const { isDarkMode } = storeToRefs(themeStore)
const router = useRouter()
const chartCanvas = ref(null) const chartCanvas = ref(null)
let chartInstance = null let chartInstance = null
@@ -579,6 +589,14 @@ const handleClose = () => {
emit('close') emit('close')
} }
const goTimeline = () => {
if (!props.account?.id) return
router.push({
path: `/accounts/${props.account.id}/usage-records`,
query: { platform: props.account.platform || props.account.accountType }
})
}
watch( watch(
() => props.show, () => props.show,
(visible) => { (visible) => {

View File

@@ -77,7 +77,21 @@ const getActionClass = (action) => {
return colorMap[action.color] || colorMap.gray return colorMap[action.color] || colorMap.gray
} }
const instanceId = Symbol('action-dropdown')
const handleGlobalOpen = (event) => {
if (event?.detail?.id !== instanceId) {
closeDropdown()
}
}
const toggleDropdown = async () => { const toggleDropdown = async () => {
if (!isOpen.value) {
window.dispatchEvent(
new CustomEvent('action-dropdown-open', {
detail: { id: instanceId }
})
)
}
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
if (isOpen.value) { if (isOpen.value) {
await nextTick() await nextTick()
@@ -102,7 +116,8 @@ const updateDropdownPosition = () => {
const trigger = triggerRef.value.getBoundingClientRect() const trigger = triggerRef.value.getBoundingClientRect()
const dropdownHeight = 200 // 预估高度 const dropdownHeight = 200 // 预估高度
const dropdownWidth = 160 // 预估宽度 const dropdownWidth = 180 // 预估宽度,略大以减少遮挡
const gap = 8
const spaceBelow = window.innerHeight - trigger.bottom const spaceBelow = window.innerHeight - trigger.bottom
const spaceAbove = trigger.top const spaceAbove = trigger.top
const spaceRight = window.innerWidth - trigger.right const spaceRight = window.innerWidth - trigger.right
@@ -112,16 +127,16 @@ const updateDropdownPosition = () => {
// 计算垂直位置 // 计算垂直位置
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) { if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
top = trigger.bottom + 4 top = trigger.bottom + gap
} else { } else {
top = trigger.top - dropdownHeight - 4 top = trigger.top - dropdownHeight - gap
} }
// 计算水平位置 - 优先显示在左侧(因为按钮在右侧固定列) // 计算水平位置 - 优先向右展开,避免遮挡左侧内容
if (spaceLeft >= dropdownWidth) { if (spaceRight >= dropdownWidth + gap) {
left = trigger.left - dropdownWidth + trigger.width left = trigger.right + gap
} else if (spaceRight >= dropdownWidth) { } else if (spaceLeft >= dropdownWidth + gap) {
left = trigger.left left = trigger.left - dropdownWidth - gap + trigger.width
} else { } else {
left = window.innerWidth - dropdownWidth - 10 left = window.innerWidth - dropdownWidth - 10
} }
@@ -164,11 +179,13 @@ onMounted(() => {
window.addEventListener('scroll', handleScroll, true) window.addEventListener('scroll', handleScroll, true)
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
window.addEventListener('action-dropdown-open', handleGlobalOpen)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
window.removeEventListener('action-dropdown-open', handleGlobalOpen)
}) })
</script> </script>

View File

@@ -13,6 +13,7 @@ const DashboardView = () => import('@/views/DashboardView.vue')
const ApiKeysView = () => import('@/views/ApiKeysView.vue') const ApiKeysView = () => import('@/views/ApiKeysView.vue')
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue') const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
const AccountsView = () => import('@/views/AccountsView.vue') const AccountsView = () => import('@/views/AccountsView.vue')
const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
const TutorialView = () => import('@/views/TutorialView.vue') const TutorialView = () => import('@/views/TutorialView.vue')
const SettingsView = () => import('@/views/SettingsView.vue') const SettingsView = () => import('@/views/SettingsView.vue')
const ApiStatsView = () => import('@/views/ApiStatsView.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', path: '/tutorial',
component: MainLayout, component: MainLayout,

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

View File

@@ -1668,7 +1668,6 @@
<i class="fas fa-chart-line" /> <i class="fas fa-chart-line" />
详情 详情
</button> </button>
<button <button
v-if="canTestAccount(account)" v-if="canTestAccount(account)"
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-cyan-50 px-3 py-2 text-xs text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50" class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-cyan-50 px-3 py-2 text-xs text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"