mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
fix: 优化apikeys页面加载速度
This commit is contained in:
@@ -166,6 +166,224 @@ class RedisClient {
|
|||||||
return apiKeys
|
return apiKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 SCAN 获取所有 API Key ID(避免 KEYS 命令阻塞)
|
||||||
|
* @returns {Promise<string[]>} API Key ID 列表
|
||||||
|
*/
|
||||||
|
async scanApiKeyIds() {
|
||||||
|
const keyIds = []
|
||||||
|
let cursor = '0'
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await this.client.scan(cursor, 'MATCH', 'apikey:*', 'COUNT', 100)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key !== 'apikey:hash_map') {
|
||||||
|
keyIds.push(key.replace('apikey:', ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return keyIds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取 API Key 数据(使用 Pipeline 优化)
|
||||||
|
* @param {string[]} keyIds - API Key ID 列表
|
||||||
|
* @returns {Promise<Object[]>} API Key 数据列表
|
||||||
|
*/
|
||||||
|
async batchGetApiKeys(keyIds) {
|
||||||
|
if (!keyIds || keyIds.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = this.client.pipeline()
|
||||||
|
for (const keyId of keyIds) {
|
||||||
|
pipeline.hgetall(`apikey:${keyId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
const apiKeys = []
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const [err, data] = results[i]
|
||||||
|
if (!err && data && Object.keys(data).length > 0) {
|
||||||
|
apiKeys.push({ id: keyIds[i], ...this._parseApiKeyData(data) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 API Key 数据,将字符串转换为正确的类型
|
||||||
|
* @param {Object} data - 原始数据
|
||||||
|
* @returns {Object} 解析后的数据
|
||||||
|
*/
|
||||||
|
_parseApiKeyData(data) {
|
||||||
|
if (!data) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = { ...data }
|
||||||
|
|
||||||
|
// 布尔字段
|
||||||
|
const boolFields = ['isActive', 'enableModelRestriction', 'isDeleted']
|
||||||
|
for (const field of boolFields) {
|
||||||
|
if (parsed[field] !== undefined) {
|
||||||
|
parsed[field] = parsed[field] === 'true'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数字字段
|
||||||
|
const numFields = [
|
||||||
|
'tokenLimit',
|
||||||
|
'dailyCostLimit',
|
||||||
|
'totalCostLimit',
|
||||||
|
'rateLimitRequests',
|
||||||
|
'rateLimitTokens',
|
||||||
|
'rateLimitWindow',
|
||||||
|
'rateLimitCost',
|
||||||
|
'maxConcurrency',
|
||||||
|
'activationDuration'
|
||||||
|
]
|
||||||
|
for (const field of numFields) {
|
||||||
|
if (parsed[field] !== undefined && parsed[field] !== '') {
|
||||||
|
parsed[field] = parseFloat(parsed[field]) || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数组字段(JSON 解析)
|
||||||
|
const arrayFields = ['tags', 'restrictedModels', 'allowedClients']
|
||||||
|
for (const field of arrayFields) {
|
||||||
|
if (parsed[field]) {
|
||||||
|
try {
|
||||||
|
parsed[field] = JSON.parse(parsed[field])
|
||||||
|
} catch (e) {
|
||||||
|
parsed[field] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 API Keys 分页数据(不含费用,用于优化列表加载)
|
||||||
|
* @param {Object} options - 分页和筛选选项
|
||||||
|
* @returns {Promise<{items: Object[], pagination: Object, availableTags: string[]}>}
|
||||||
|
*/
|
||||||
|
async getApiKeysPaginated(options = {}) {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
searchMode = 'apiKey',
|
||||||
|
search = '',
|
||||||
|
tag = '',
|
||||||
|
isActive = '',
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
excludeDeleted = true // 默认排除已删除的 API Keys
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// 1. 使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞)
|
||||||
|
const keyIds = await this.scanApiKeyIds()
|
||||||
|
|
||||||
|
// 2. 使用 Pipeline 批量获取基础数据
|
||||||
|
const apiKeys = await this.batchGetApiKeys(keyIds)
|
||||||
|
|
||||||
|
// 3. 应用筛选条件
|
||||||
|
let filteredKeys = apiKeys
|
||||||
|
|
||||||
|
// 排除已删除的 API Keys(默认行为)
|
||||||
|
if (excludeDeleted) {
|
||||||
|
filteredKeys = filteredKeys.filter((k) => !k.isDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if (isActive !== '' && isActive !== undefined && isActive !== null) {
|
||||||
|
const activeValue = isActive === 'true' || isActive === true
|
||||||
|
filteredKeys = filteredKeys.filter((k) => k.isActive === activeValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签筛选
|
||||||
|
if (tag) {
|
||||||
|
filteredKeys = filteredKeys.filter((k) => {
|
||||||
|
const tags = Array.isArray(k.tags) ? k.tags : []
|
||||||
|
return tags.includes(tag)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索(apiKey 模式在这里处理,bindingAccount 模式在路由层处理)
|
||||||
|
if (search && searchMode === 'apiKey') {
|
||||||
|
const lowerSearch = search.toLowerCase().trim()
|
||||||
|
filteredKeys = filteredKeys.filter(
|
||||||
|
(k) =>
|
||||||
|
(k.name && k.name.toLowerCase().includes(lowerSearch)) ||
|
||||||
|
(k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 排序
|
||||||
|
filteredKeys.sort((a, b) => {
|
||||||
|
let aVal = a[sortBy]
|
||||||
|
let bVal = b[sortBy]
|
||||||
|
|
||||||
|
// 日期字段转时间戳
|
||||||
|
if (['createdAt', 'expiresAt', 'lastUsedAt'].includes(sortBy)) {
|
||||||
|
aVal = aVal ? new Date(aVal).getTime() : 0
|
||||||
|
bVal = bVal ? new Date(bVal).getTime() : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 布尔字段转数字
|
||||||
|
if (sortBy === 'isActive' || sortBy === 'status') {
|
||||||
|
aVal = aVal ? 1 : 0
|
||||||
|
bVal = bVal ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字符串字段
|
||||||
|
if (sortBy === 'name') {
|
||||||
|
aVal = (aVal || '').toLowerCase()
|
||||||
|
bVal = (bVal || '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aVal < bVal) {
|
||||||
|
return sortOrder === 'asc' ? -1 : 1
|
||||||
|
}
|
||||||
|
if (aVal > bVal) {
|
||||||
|
return sortOrder === 'asc' ? 1 : -1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. 收集所有可用标签(在分页之前)
|
||||||
|
const allTags = new Set()
|
||||||
|
for (const key of apiKeys) {
|
||||||
|
const tags = Array.isArray(key.tags) ? key.tags : []
|
||||||
|
tags.forEach((t) => allTags.add(t))
|
||||||
|
}
|
||||||
|
const availableTags = [...allTags].sort()
|
||||||
|
|
||||||
|
// 6. 分页
|
||||||
|
const total = filteredKeys.length
|
||||||
|
const totalPages = Math.ceil(total / pageSize) || 1
|
||||||
|
const validPage = Math.min(Math.max(1, page), totalPages)
|
||||||
|
const start = (validPage - 1) * pageSize
|
||||||
|
const items = filteredKeys.slice(start, start + pageSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pagination: {
|
||||||
|
page: validPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages
|
||||||
|
},
|
||||||
|
availableTags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔍 通过哈希值查找API Key(性能优化)
|
// 🔍 通过哈希值查找API Key(性能优化)
|
||||||
async findApiKeyByHash(hashedKey) {
|
async findApiKeyByHash(hashedKey) {
|
||||||
// 使用反向映射表:hash -> keyId
|
// 使用反向映射表:hash -> keyId
|
||||||
|
|||||||
@@ -193,323 +193,88 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
|
|||||||
// 获取所有API Keys
|
// 获取所有API Keys
|
||||||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom
|
const {
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
// 分页参数
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
// 搜索参数
|
||||||
|
searchMode = 'apiKey',
|
||||||
|
search = '',
|
||||||
|
// 筛选参数
|
||||||
|
tag = '',
|
||||||
|
isActive = '',
|
||||||
|
// 排序参数
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
// 兼容旧参数(不再用于费用计算,仅标记)
|
||||||
|
timeRange = 'all'
|
||||||
|
} = req.query
|
||||||
|
|
||||||
|
// 验证分页参数
|
||||||
|
const pageNum = Math.max(1, parseInt(page) || 1)
|
||||||
|
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
|
||||||
|
|
||||||
|
// 验证排序参数(移除费用相关排序)
|
||||||
|
const validSortFields = ['name', 'createdAt', 'expiresAt', 'lastUsedAt', 'isActive', 'status']
|
||||||
|
const validSortBy = validSortFields.includes(sortBy) ? sortBy : 'createdAt'
|
||||||
|
const validSortOrder = ['asc', 'desc'].includes(sortOrder) ? sortOrder : 'desc'
|
||||||
|
|
||||||
// 获取用户服务来补充owner信息
|
// 获取用户服务来补充owner信息
|
||||||
const userService = require('../services/userService')
|
const userService = require('../services/userService')
|
||||||
|
|
||||||
// 根据时间范围计算查询模式
|
// 使用优化的分页方法获取数据
|
||||||
const now = new Date()
|
let result = await redis.getApiKeysPaginated({
|
||||||
const searchPatterns = []
|
page: pageNum,
|
||||||
|
pageSize: pageSizeNum,
|
||||||
if (timeRange === 'custom' && startDate && endDate) {
|
searchMode,
|
||||||
// 自定义日期范围
|
search: searchMode === 'apiKey' ? search : '', // apiKey 模式的搜索在 redis 层处理
|
||||||
const redisClient = require('../models/redis')
|
tag,
|
||||||
const start = new Date(startDate)
|
isActive,
|
||||||
const end = new Date(endDate)
|
sortBy: validSortBy,
|
||||||
|
sortOrder: validSortOrder
|
||||||
// 确保日期范围有效
|
|
||||||
if (start > end) {
|
|
||||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 限制最大范围为365天
|
|
||||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
|
||||||
if (daysDiff > 365) {
|
|
||||||
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成日期范围内每天的搜索模式
|
|
||||||
const currentDate = new Date(start)
|
|
||||||
while (currentDate <= end) {
|
|
||||||
const tzDate = redisClient.getDateInTimezone(currentDate)
|
|
||||||
const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
|
||||||
2,
|
|
||||||
'0'
|
|
||||||
)}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
|
|
||||||
searchPatterns.push(`usage:daily:*:${dateStr}`)
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1)
|
|
||||||
}
|
|
||||||
} else if (timeRange === 'today') {
|
|
||||||
// 今日 - 使用时区日期
|
|
||||||
const redisClient = require('../models/redis')
|
|
||||||
const tzDate = redisClient.getDateInTimezone(now)
|
|
||||||
const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
|
||||||
2,
|
|
||||||
'0'
|
|
||||||
)}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
|
|
||||||
searchPatterns.push(`usage:daily:*:${dateStr}`)
|
|
||||||
} else if (timeRange === '7days') {
|
|
||||||
// 最近7天
|
|
||||||
const redisClient = require('../models/redis')
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const date = new Date(now)
|
|
||||||
date.setDate(date.getDate() - i)
|
|
||||||
const tzDate = redisClient.getDateInTimezone(date)
|
|
||||||
const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
|
||||||
2,
|
|
||||||
'0'
|
|
||||||
)}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
|
|
||||||
searchPatterns.push(`usage:daily:*:${dateStr}`)
|
|
||||||
}
|
|
||||||
} else if (timeRange === 'monthly') {
|
|
||||||
// 本月
|
|
||||||
const redisClient = require('../models/redis')
|
|
||||||
const tzDate = redisClient.getDateInTimezone(now)
|
|
||||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
|
||||||
2,
|
|
||||||
'0'
|
|
||||||
)}`
|
|
||||||
searchPatterns.push(`usage:monthly:*:${currentMonth}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个API Key计算准确的费用和统计数据
|
|
||||||
for (const apiKey of apiKeys) {
|
|
||||||
const client = redis.getClientSafe()
|
|
||||||
|
|
||||||
if (timeRange === 'all') {
|
|
||||||
// 全部时间:保持原有逻辑
|
|
||||||
if (apiKey.usage && apiKey.usage.total) {
|
|
||||||
// 使用与展开模型统计相同的数据源
|
|
||||||
// 获取所有时间的模型统计数据
|
|
||||||
const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`)
|
|
||||||
const modelStatsMap = new Map()
|
|
||||||
|
|
||||||
// 汇总所有月份的数据
|
|
||||||
for (const key of monthlyKeys) {
|
|
||||||
const match = key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/)
|
|
||||||
if (!match) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = match[1]
|
|
||||||
const data = await client.hgetall(key)
|
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
if (!modelStatsMap.has(model)) {
|
|
||||||
modelStatsMap.set(model, {
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
cacheCreateTokens: 0,
|
|
||||||
cacheReadTokens: 0
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const stats = modelStatsMap.get(model)
|
// 如果是绑定账号搜索模式,需要在这里处理
|
||||||
stats.inputTokens +=
|
if (searchMode === 'bindingAccount' && search) {
|
||||||
parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
|
const accountNameCacheService = require('../services/accountNameCacheService')
|
||||||
stats.outputTokens +=
|
await accountNameCacheService.refreshIfNeeded()
|
||||||
parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
|
|
||||||
stats.cacheCreateTokens +=
|
|
||||||
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
|
|
||||||
stats.cacheReadTokens +=
|
|
||||||
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalCost = 0
|
// 获取所有数据进行绑定账号搜索
|
||||||
|
const allResult = await redis.getApiKeysPaginated({
|
||||||
// 计算每个模型的费用
|
page: 1,
|
||||||
for (const [model, stats] of modelStatsMap) {
|
pageSize: 10000, // 获取所有数据
|
||||||
const usage = {
|
searchMode: 'apiKey',
|
||||||
input_tokens: stats.inputTokens,
|
search: '',
|
||||||
output_tokens: stats.outputTokens,
|
tag,
|
||||||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
isActive,
|
||||||
cache_read_input_tokens: stats.cacheReadTokens
|
sortBy: validSortBy,
|
||||||
}
|
sortOrder: validSortOrder
|
||||||
|
|
||||||
const costResult = CostCalculator.calculateCost(usage, model)
|
|
||||||
totalCost += costResult.costs.total
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有详细的模型数据,使用总量数据和默认模型计算
|
|
||||||
if (modelStatsMap.size === 0) {
|
|
||||||
const usage = {
|
|
||||||
input_tokens: apiKey.usage.total.inputTokens || 0,
|
|
||||||
output_tokens: apiKey.usage.total.outputTokens || 0,
|
|
||||||
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
|
|
||||||
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022')
|
|
||||||
totalCost = costResult.costs.total
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加格式化的费用到响应数据
|
|
||||||
apiKey.usage.total.cost = totalCost
|
|
||||||
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 7天、本月或自定义日期范围:重新计算统计数据
|
|
||||||
const tempUsage = {
|
|
||||||
requests: 0,
|
|
||||||
tokens: 0,
|
|
||||||
allTokens: 0, // 添加allTokens字段
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
cacheCreateTokens: 0,
|
|
||||||
cacheReadTokens: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取指定时间范围的统计数据
|
|
||||||
for (const pattern of searchPatterns) {
|
|
||||||
const keys = await client.keys(pattern.replace('*', apiKey.id))
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
const data = await client.hgetall(key)
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
// 使用与 redis.js incrementTokenUsage 中相同的字段名
|
|
||||||
tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0
|
|
||||||
tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0
|
|
||||||
tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 // 读取包含所有Token的字段
|
|
||||||
tempUsage.inputTokens +=
|
|
||||||
parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
|
|
||||||
tempUsage.outputTokens +=
|
|
||||||
parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
|
|
||||||
tempUsage.cacheCreateTokens +=
|
|
||||||
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
|
|
||||||
tempUsage.cacheReadTokens +=
|
|
||||||
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算指定时间范围的费用
|
|
||||||
let totalCost = 0
|
|
||||||
const redisClient = require('../models/redis')
|
|
||||||
const tzToday = redisClient.getDateStringInTimezone(now)
|
|
||||||
const tzDate = redisClient.getDateInTimezone(now)
|
|
||||||
const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
|
|
||||||
2,
|
|
||||||
'0'
|
|
||||||
)}`
|
|
||||||
|
|
||||||
let modelKeys = []
|
|
||||||
if (timeRange === 'custom' && startDate && endDate) {
|
|
||||||
// 自定义日期范围:获取范围内所有日期的模型统计
|
|
||||||
const start = new Date(startDate)
|
|
||||||
const end = new Date(endDate)
|
|
||||||
const currentDate = new Date(start)
|
|
||||||
|
|
||||||
while (currentDate <= end) {
|
|
||||||
const tzDateForKey = redisClient.getDateInTimezone(currentDate)
|
|
||||||
const dateStr = `${tzDateForKey.getUTCFullYear()}-${String(
|
|
||||||
tzDateForKey.getUTCMonth() + 1
|
|
||||||
).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}`
|
|
||||||
const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`)
|
|
||||||
modelKeys = modelKeys.concat(dayKeys)
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
modelKeys =
|
|
||||||
timeRange === 'today'
|
|
||||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
|
|
||||||
: timeRange === '7days'
|
|
||||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
|
|
||||||
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelStatsMap = new Map()
|
|
||||||
|
|
||||||
// 过滤和汇总相应时间范围的模型数据
|
|
||||||
for (const key of modelKeys) {
|
|
||||||
if (timeRange === '7days') {
|
|
||||||
// 检查是否在最近7天内
|
|
||||||
const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/)
|
|
||||||
if (dateMatch) {
|
|
||||||
const keyDate = new Date(dateMatch[0])
|
|
||||||
const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24))
|
|
||||||
if (daysDiff > 6) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (timeRange === 'today' || timeRange === 'custom') {
|
|
||||||
// today和custom选项已经在查询时过滤了,不需要额外处理
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelMatch = key.match(
|
|
||||||
/usage:.+:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\d{2})?$/
|
|
||||||
)
|
|
||||||
if (!modelMatch) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = modelMatch[1]
|
|
||||||
const data = await client.hgetall(key)
|
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
if (!modelStatsMap.has(model)) {
|
|
||||||
modelStatsMap.set(model, {
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
cacheCreateTokens: 0,
|
|
||||||
cacheReadTokens: 0
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const stats = modelStatsMap.get(model)
|
// 使用缓存服务进行绑定账号搜索
|
||||||
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
|
const filteredKeys = accountNameCacheService.searchByBindingAccount(allResult.items, search)
|
||||||
stats.outputTokens +=
|
|
||||||
parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
|
|
||||||
stats.cacheCreateTokens +=
|
|
||||||
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
|
|
||||||
stats.cacheReadTokens +=
|
|
||||||
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算费用
|
// 重新分页
|
||||||
for (const [model, stats] of modelStatsMap) {
|
const total = filteredKeys.length
|
||||||
const usage = {
|
const totalPages = Math.ceil(total / pageSizeNum) || 1
|
||||||
input_tokens: stats.inputTokens,
|
const validPage = Math.min(Math.max(1, pageNum), totalPages)
|
||||||
output_tokens: stats.outputTokens,
|
const start = (validPage - 1) * pageSizeNum
|
||||||
cache_creation_input_tokens: stats.cacheCreateTokens,
|
const items = filteredKeys.slice(start, start + pageSizeNum)
|
||||||
cache_read_input_tokens: stats.cacheReadTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
const costResult = CostCalculator.calculateCost(usage, model)
|
result = {
|
||||||
totalCost += costResult.costs.total
|
items,
|
||||||
}
|
pagination: {
|
||||||
|
page: validPage,
|
||||||
// 如果没有模型数据,使用临时统计数据计算
|
pageSize: pageSizeNum,
|
||||||
if (modelStatsMap.size === 0 && tempUsage.tokens > 0) {
|
total,
|
||||||
const usage = {
|
totalPages
|
||||||
input_tokens: tempUsage.inputTokens,
|
},
|
||||||
output_tokens: tempUsage.outputTokens,
|
availableTags: allResult.availableTags
|
||||||
cache_creation_input_tokens: tempUsage.cacheCreateTokens,
|
|
||||||
cache_read_input_tokens: tempUsage.cacheReadTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022')
|
|
||||||
totalCost = costResult.costs.total
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用从Redis读取的allTokens,如果没有则计算
|
|
||||||
const allTokens =
|
|
||||||
tempUsage.allTokens ||
|
|
||||||
tempUsage.inputTokens +
|
|
||||||
tempUsage.outputTokens +
|
|
||||||
tempUsage.cacheCreateTokens +
|
|
||||||
tempUsage.cacheReadTokens
|
|
||||||
|
|
||||||
// 更新API Key的usage数据为指定时间范围的数据
|
|
||||||
apiKey.usage[timeRange] = {
|
|
||||||
...tempUsage,
|
|
||||||
tokens: allTokens, // 使用包含所有Token的总数
|
|
||||||
allTokens,
|
|
||||||
cost: totalCost,
|
|
||||||
formattedCost: CostCalculator.formatCost(totalCost)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为了保持兼容性,也更新total字段
|
|
||||||
apiKey.usage.total = apiKey.usage[timeRange]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为每个API Key添加owner的displayName
|
// 为每个API Key添加owner的displayName
|
||||||
for (const apiKey of apiKeys) {
|
for (const apiKey of result.items) {
|
||||||
// 如果API Key有关联的用户ID,获取用户信息
|
|
||||||
if (apiKey.userId) {
|
if (apiKey.userId) {
|
||||||
try {
|
try {
|
||||||
const user = await userService.getUserById(apiKey.userId, false)
|
const user = await userService.getUserById(apiKey.userId, false)
|
||||||
@@ -523,13 +288,27 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
apiKey.ownerDisplayName = 'Unknown User'
|
apiKey.ownerDisplayName = 'Unknown User'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果没有userId,使用createdBy字段或默认为Admin
|
|
||||||
apiKey.ownerDisplayName =
|
apiKey.ownerDisplayName =
|
||||||
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
|
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化空的 usage 对象(费用通过 batch-stats 接口获取)
|
||||||
|
if (!apiKey.usage) {
|
||||||
|
apiKey.usage = { total: { requests: 0, tokens: 0, cost: 0, formattedCost: '$0.00' } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ success: true, data: apiKeys })
|
// 返回分页数据
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: result.items,
|
||||||
|
pagination: result.pagination,
|
||||||
|
availableTags: result.availableTags
|
||||||
|
},
|
||||||
|
// 标记当前请求的时间范围(供前端参考)
|
||||||
|
timeRange
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to get API keys:', error)
|
logger.error('❌ Failed to get API keys:', error)
|
||||||
return res.status(500).json({ error: 'Failed to get API keys', message: error.message })
|
return res.status(500).json({ error: 'Failed to get API keys', message: error.message })
|
||||||
@@ -589,6 +368,382 @@ router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户绑定的 API Key 数量统计
|
||||||
|
* GET /admin/accounts/binding-counts
|
||||||
|
*
|
||||||
|
* 返回每种账户类型的绑定数量统计,用于账户列表页面显示"绑定: X 个API Key"
|
||||||
|
* 这是一个轻量级接口,只返回计数而不是完整的 API Key 数据
|
||||||
|
*/
|
||||||
|
router.get('/accounts/binding-counts', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 使用优化的分页方法获取所有非删除的 API Keys(只需要绑定字段)
|
||||||
|
const result = await redis.getApiKeysPaginated({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10000, // 获取所有
|
||||||
|
excludeDeleted: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiKeys = result.items
|
||||||
|
|
||||||
|
// 初始化统计对象
|
||||||
|
const bindingCounts = {
|
||||||
|
claudeAccountId: {},
|
||||||
|
claudeConsoleAccountId: {},
|
||||||
|
geminiAccountId: {},
|
||||||
|
openaiAccountId: {},
|
||||||
|
azureOpenaiAccountId: {},
|
||||||
|
bedrockAccountId: {},
|
||||||
|
droidAccountId: {},
|
||||||
|
ccrAccountId: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历一次,统计每个账户的绑定数量
|
||||||
|
for (const key of apiKeys) {
|
||||||
|
// Claude 账户
|
||||||
|
if (key.claudeAccountId) {
|
||||||
|
const id = key.claudeAccountId
|
||||||
|
bindingCounts.claudeAccountId[id] = (bindingCounts.claudeAccountId[id] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Console 账户
|
||||||
|
if (key.claudeConsoleAccountId) {
|
||||||
|
const id = key.claudeConsoleAccountId
|
||||||
|
bindingCounts.claudeConsoleAccountId[id] =
|
||||||
|
(bindingCounts.claudeConsoleAccountId[id] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini 账户(包括 api: 前缀的 Gemini-API 账户)
|
||||||
|
if (key.geminiAccountId) {
|
||||||
|
const id = key.geminiAccountId
|
||||||
|
bindingCounts.geminiAccountId[id] = (bindingCounts.geminiAccountId[id] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 账户(包括 responses: 前缀的 OpenAI-Responses 账户)
|
||||||
|
if (key.openaiAccountId) {
|
||||||
|
const id = key.openaiAccountId
|
||||||
|
bindingCounts.openaiAccountId[id] = (bindingCounts.openaiAccountId[id] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Azure OpenAI 账户
|
||||||
|
if (key.azureOpenaiAccountId) {
|
||||||
|
const id = key.azureOpenaiAccountId
|
||||||
|
bindingCounts.azureOpenaiAccountId[id] = (bindingCounts.azureOpenaiAccountId[id] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock 账户
|
||||||
|
if (key.bedrockAccountId) {
|
||||||
|
const id = key.bedrockAccountId
|
||||||
|
bindingCounts.bedrockAccountId[id] = (bindingCounts.bedrockAccountId[id] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Droid 账户
|
||||||
|
if (key.droidAccountId) {
|
||||||
|
const id = key.droidAccountId
|
||||||
|
bindingCounts.droidAccountId[id] = (bindingCounts.droidAccountId[id] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// CCR 账户
|
||||||
|
if (key.ccrAccountId) {
|
||||||
|
const id = key.ccrAccountId
|
||||||
|
bindingCounts.ccrAccountId[id] = (bindingCounts.ccrAccountId[id] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`📊 Account binding counts calculated from ${apiKeys.length} API keys`)
|
||||||
|
return res.json({ success: true, data: bindingCounts })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get account binding counts:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get account binding counts',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取指定 Keys 的统计数据和费用
|
||||||
|
* POST /admin/api-keys/batch-stats
|
||||||
|
*
|
||||||
|
* 用于 API Keys 列表页面异步加载统计数据
|
||||||
|
*/
|
||||||
|
router.post('/api-keys/batch-stats', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
keyIds, // 必需:API Key ID 数组
|
||||||
|
timeRange = 'all', // 时间范围:all, today, 7days, monthly, custom
|
||||||
|
startDate, // custom 时必需
|
||||||
|
endDate // custom 时必需
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
// 参数验证
|
||||||
|
if (!Array.isArray(keyIds) || keyIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'keyIds is required and must be a non-empty array'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制单次最多处理 100 个 Key
|
||||||
|
if (keyIds.length > 100) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Max 100 keys per request'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 custom 时间范围的参数
|
||||||
|
if (timeRange === 'custom') {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'startDate and endDate are required for custom time range'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const start = new Date(startDate)
|
||||||
|
const end = new Date(endDate)
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid date format'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (start > end) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'startDate must be before or equal to endDate'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 限制最大范围为 365 天
|
||||||
|
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||||
|
if (daysDiff > 365) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Date range cannot exceed 365 days'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`📊 Batch stats request: ${keyIds.length} keys, timeRange=${timeRange}`,
|
||||||
|
timeRange === 'custom' ? `, ${startDate} to ${endDate}` : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = {}
|
||||||
|
|
||||||
|
// 并行计算每个 Key 的统计数据
|
||||||
|
await Promise.all(
|
||||||
|
keyIds.map(async (keyId) => {
|
||||||
|
try {
|
||||||
|
stats[keyId] = await calculateKeyStats(keyId, timeRange, startDate, endDate)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to calculate stats for key ${keyId}:`, error)
|
||||||
|
stats[keyId] = {
|
||||||
|
requests: 0,
|
||||||
|
tokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
formattedCost: '$0.00',
|
||||||
|
error: error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({ success: true, data: stats })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to calculate batch stats:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to calculate stats',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算单个 Key 的统计数据
|
||||||
|
* @param {string} keyId - API Key ID
|
||||||
|
* @param {string} timeRange - 时间范围
|
||||||
|
* @param {string} startDate - 开始日期 (custom 模式)
|
||||||
|
* @param {string} endDate - 结束日期 (custom 模式)
|
||||||
|
* @returns {Object} 统计数据
|
||||||
|
*/
|
||||||
|
async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const tzDate = redis.getDateInTimezone()
|
||||||
|
const today = redis.getDateStringInTimezone()
|
||||||
|
|
||||||
|
// 构建搜索模式
|
||||||
|
const searchPatterns = []
|
||||||
|
|
||||||
|
if (timeRange === 'custom' && startDate && endDate) {
|
||||||
|
// 自定义日期范围
|
||||||
|
const start = new Date(startDate)
|
||||||
|
const end = new Date(endDate)
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
const dateStr = redis.getDateStringInTimezone(d)
|
||||||
|
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`)
|
||||||
|
}
|
||||||
|
} else if (timeRange === 'today') {
|
||||||
|
searchPatterns.push(`usage:${keyId}:model:daily:*:${today}`)
|
||||||
|
} else if (timeRange === '7days') {
|
||||||
|
// 最近7天
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(tzDate)
|
||||||
|
d.setDate(d.getDate() - i)
|
||||||
|
const dateStr = redis.getDateStringInTimezone(d)
|
||||||
|
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`)
|
||||||
|
}
|
||||||
|
} else if (timeRange === 'monthly') {
|
||||||
|
// 当月
|
||||||
|
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||||
|
searchPatterns.push(`usage:${keyId}:model:monthly:*:${currentMonth}`)
|
||||||
|
} else {
|
||||||
|
// all - 获取所有数据(日和月数据都查)
|
||||||
|
searchPatterns.push(`usage:${keyId}:model:daily:*`)
|
||||||
|
searchPatterns.push(`usage:${keyId}:model:monthly:*`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 SCAN 收集所有匹配的 keys
|
||||||
|
const allKeys = []
|
||||||
|
for (const pattern of searchPatterns) {
|
||||||
|
let cursor = '0'
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100)
|
||||||
|
cursor = newCursor
|
||||||
|
allKeys.push(...keys)
|
||||||
|
} while (cursor !== '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重(避免日数据和月数据重复计算)
|
||||||
|
const uniqueKeys = [...new Set(allKeys)]
|
||||||
|
|
||||||
|
if (uniqueKeys.length === 0) {
|
||||||
|
return {
|
||||||
|
requests: 0,
|
||||||
|
tokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
formattedCost: '$0.00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Pipeline 批量获取数据
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const key of uniqueKeys) {
|
||||||
|
pipeline.hgetall(key)
|
||||||
|
}
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
// 汇总计算
|
||||||
|
const modelStatsMap = new Map()
|
||||||
|
let totalRequests = 0
|
||||||
|
|
||||||
|
// 用于去重:只统计日数据,避免与月数据重复
|
||||||
|
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||||
|
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||||
|
|
||||||
|
// 检查是否有日数据
|
||||||
|
const hasDailyData = uniqueKeys.some((key) => dailyKeyPattern.test(key))
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const [err, data] = results[i]
|
||||||
|
if (err || !data || Object.keys(data).length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = uniqueKeys[i]
|
||||||
|
let model = null
|
||||||
|
let isMonthly = false
|
||||||
|
|
||||||
|
// 提取模型名称
|
||||||
|
const dailyMatch = key.match(dailyKeyPattern)
|
||||||
|
const monthlyMatch = key.match(monthlyKeyPattern)
|
||||||
|
|
||||||
|
if (dailyMatch) {
|
||||||
|
model = dailyMatch[1]
|
||||||
|
} else if (monthlyMatch) {
|
||||||
|
model = monthlyMatch[1]
|
||||||
|
isMonthly = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有日数据,则跳过月数据以避免重复
|
||||||
|
if (hasDailyData && isMonthly) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelStatsMap.has(model)) {
|
||||||
|
modelStatsMap.set(model, {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
requests: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = modelStatsMap.get(model)
|
||||||
|
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
|
||||||
|
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
|
||||||
|
stats.cacheCreateTokens +=
|
||||||
|
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
|
||||||
|
stats.cacheReadTokens +=
|
||||||
|
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
|
||||||
|
stats.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0
|
||||||
|
|
||||||
|
totalRequests += parseInt(data.totalRequests) || parseInt(data.requests) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算费用
|
||||||
|
let totalCost = 0
|
||||||
|
let inputTokens = 0
|
||||||
|
let outputTokens = 0
|
||||||
|
let cacheCreateTokens = 0
|
||||||
|
let cacheReadTokens = 0
|
||||||
|
|
||||||
|
for (const [model, stats] of modelStatsMap) {
|
||||||
|
inputTokens += stats.inputTokens
|
||||||
|
outputTokens += stats.outputTokens
|
||||||
|
cacheCreateTokens += stats.cacheCreateTokens
|
||||||
|
cacheReadTokens += stats.cacheReadTokens
|
||||||
|
|
||||||
|
const costResult = CostCalculator.calculateCost(
|
||||||
|
{
|
||||||
|
input_tokens: stats.inputTokens,
|
||||||
|
output_tokens: stats.outputTokens,
|
||||||
|
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||||||
|
cache_read_input_tokens: stats.cacheReadTokens
|
||||||
|
},
|
||||||
|
model
|
||||||
|
)
|
||||||
|
totalCost += costResult.costs.total
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||||
|
|
||||||
|
return {
|
||||||
|
requests: totalRequests,
|
||||||
|
tokens,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cacheCreateTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cost: totalCost,
|
||||||
|
formattedCost: CostCalculator.formatCost(totalCost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 创建新的API Key
|
// 创建新的API Key
|
||||||
router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
286
src/services/accountNameCacheService.js
Normal file
286
src/services/accountNameCacheService.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* 账户名称缓存服务
|
||||||
|
* 用于加速绑定账号搜索,避免每次搜索都查询所有账户
|
||||||
|
*/
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
class AccountNameCacheService {
|
||||||
|
constructor() {
|
||||||
|
// 账户名称缓存:accountId -> { name, platform }
|
||||||
|
this.accountCache = new Map()
|
||||||
|
// 账户组名称缓存:groupId -> { name, platform }
|
||||||
|
this.groupCache = new Map()
|
||||||
|
// 缓存过期时间
|
||||||
|
this.lastRefresh = 0
|
||||||
|
this.refreshInterval = 5 * 60 * 1000 // 5分钟
|
||||||
|
this.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新缓存(如果过期)
|
||||||
|
*/
|
||||||
|
async refreshIfNeeded() {
|
||||||
|
if (Date.now() - this.lastRefresh < this.refreshInterval) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.isRefreshing) {
|
||||||
|
// 等待正在进行的刷新完成
|
||||||
|
let waitCount = 0
|
||||||
|
while (this.isRefreshing && waitCount < 50) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
waitCount++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制刷新缓存
|
||||||
|
*/
|
||||||
|
async refresh() {
|
||||||
|
if (this.isRefreshing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newAccountCache = new Map()
|
||||||
|
const newGroupCache = new Map()
|
||||||
|
|
||||||
|
// 延迟加载服务,避免循环依赖
|
||||||
|
const claudeAccountService = require('./claudeAccountService')
|
||||||
|
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
|
||||||
|
const geminiAccountService = require('./geminiAccountService')
|
||||||
|
const openaiAccountService = require('./openaiAccountService')
|
||||||
|
const azureOpenaiAccountService = require('./azureOpenaiAccountService')
|
||||||
|
const bedrockAccountService = require('./bedrockAccountService')
|
||||||
|
const droidAccountService = require('./droidAccountService')
|
||||||
|
const ccrAccountService = require('./ccrAccountService')
|
||||||
|
const accountGroupService = require('./accountGroupService')
|
||||||
|
|
||||||
|
// 可选服务(可能不存在)
|
||||||
|
let geminiApiAccountService = null
|
||||||
|
let openaiResponsesAccountService = null
|
||||||
|
try {
|
||||||
|
geminiApiAccountService = require('./geminiApiAccountService')
|
||||||
|
} catch (e) {
|
||||||
|
// 服务不存在,忽略
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||||
|
} catch (e) {
|
||||||
|
// 服务不存在,忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并行加载所有账户类型
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
claudeAccountService.getAllAccounts(),
|
||||||
|
claudeConsoleAccountService.getAllAccounts(),
|
||||||
|
geminiAccountService.getAllAccounts(),
|
||||||
|
geminiApiAccountService?.getAllAccounts() || Promise.resolve([]),
|
||||||
|
openaiAccountService.getAllAccounts(),
|
||||||
|
openaiResponsesAccountService?.getAllAccounts() || Promise.resolve([]),
|
||||||
|
azureOpenaiAccountService.getAllAccounts(),
|
||||||
|
bedrockAccountService.getAllAccounts(),
|
||||||
|
droidAccountService.getAllAccounts(),
|
||||||
|
ccrAccountService.getAllAccounts(),
|
||||||
|
accountGroupService.getAllGroups()
|
||||||
|
])
|
||||||
|
|
||||||
|
// 提取结果
|
||||||
|
const claudeAccounts = results[0].status === 'fulfilled' ? results[0].value : []
|
||||||
|
const claudeConsoleAccounts = results[1].status === 'fulfilled' ? results[1].value : []
|
||||||
|
const geminiAccounts = results[2].status === 'fulfilled' ? results[2].value : []
|
||||||
|
const geminiApiAccounts = results[3].status === 'fulfilled' ? results[3].value : []
|
||||||
|
const openaiAccounts = results[4].status === 'fulfilled' ? results[4].value : []
|
||||||
|
const openaiResponsesAccounts = results[5].status === 'fulfilled' ? results[5].value : []
|
||||||
|
const azureOpenaiAccounts = results[6].status === 'fulfilled' ? results[6].value : []
|
||||||
|
const bedrockResult = results[7].status === 'fulfilled' ? results[7].value : { accounts: [] }
|
||||||
|
const droidAccounts = results[8].status === 'fulfilled' ? results[8].value : []
|
||||||
|
const ccrAccounts = results[9].status === 'fulfilled' ? results[9].value : []
|
||||||
|
const groups = results[10].status === 'fulfilled' ? results[10].value : []
|
||||||
|
|
||||||
|
// Bedrock 返回格式特殊处理
|
||||||
|
const bedrockAccounts = Array.isArray(bedrockResult)
|
||||||
|
? bedrockResult
|
||||||
|
: bedrockResult.accounts || []
|
||||||
|
|
||||||
|
// 填充账户缓存的辅助函数
|
||||||
|
const addAccounts = (accounts, platform, prefix = '') => {
|
||||||
|
if (!Array.isArray(accounts)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const acc of accounts) {
|
||||||
|
if (acc && acc.id && acc.name) {
|
||||||
|
const key = prefix ? `${prefix}${acc.id}` : acc.id
|
||||||
|
newAccountCache.set(key, { name: acc.name, platform })
|
||||||
|
// 同时存储不带前缀的版本,方便查找
|
||||||
|
if (prefix) {
|
||||||
|
newAccountCache.set(acc.id, { name: acc.name, platform })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addAccounts(claudeAccounts, 'claude')
|
||||||
|
addAccounts(claudeConsoleAccounts, 'claude-console')
|
||||||
|
addAccounts(geminiAccounts, 'gemini')
|
||||||
|
addAccounts(geminiApiAccounts, 'gemini-api', 'api:')
|
||||||
|
addAccounts(openaiAccounts, 'openai')
|
||||||
|
addAccounts(openaiResponsesAccounts, 'openai-responses', 'responses:')
|
||||||
|
addAccounts(azureOpenaiAccounts, 'azure-openai')
|
||||||
|
addAccounts(bedrockAccounts, 'bedrock')
|
||||||
|
addAccounts(droidAccounts, 'droid')
|
||||||
|
addAccounts(ccrAccounts, 'ccr')
|
||||||
|
|
||||||
|
// 填充账户组缓存
|
||||||
|
if (Array.isArray(groups)) {
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group && group.id && group.name) {
|
||||||
|
newGroupCache.set(group.id, { name: group.name, platform: group.platform })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.accountCache = newAccountCache
|
||||||
|
this.groupCache = newGroupCache
|
||||||
|
this.lastRefresh = Date.now()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`账户名称缓存已刷新: ${newAccountCache.size} 个账户, ${newGroupCache.size} 个分组`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('刷新账户名称缓存失败:', error)
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户显示名称
|
||||||
|
* @param {string} accountId - 账户ID(可能带前缀)
|
||||||
|
* @param {string} _fieldName - 字段名(如 claudeAccountId),保留用于将来扩展
|
||||||
|
* @returns {string} 显示名称
|
||||||
|
*/
|
||||||
|
getAccountDisplayName(accountId, _fieldName) {
|
||||||
|
if (!accountId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理账户组
|
||||||
|
if (accountId.startsWith('group:')) {
|
||||||
|
const groupId = accountId.substring(6)
|
||||||
|
const group = this.groupCache.get(groupId)
|
||||||
|
if (group) {
|
||||||
|
return `分组-${group.name}`
|
||||||
|
}
|
||||||
|
return `分组-${groupId.substring(0, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接查找(包括带前缀的 api:xxx, responses:xxx)
|
||||||
|
const cached = this.accountCache.get(accountId)
|
||||||
|
if (cached) {
|
||||||
|
return cached.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试去掉前缀查找
|
||||||
|
let realId = accountId
|
||||||
|
if (accountId.startsWith('api:')) {
|
||||||
|
realId = accountId.substring(4)
|
||||||
|
} else if (accountId.startsWith('responses:')) {
|
||||||
|
realId = accountId.substring(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (realId !== accountId) {
|
||||||
|
const cached2 = this.accountCache.get(realId)
|
||||||
|
if (cached2) {
|
||||||
|
return cached2.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未找到,返回 ID 前缀
|
||||||
|
return `${accountId.substring(0, 8)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 API Key 的所有绑定账户显示名称
|
||||||
|
* @param {Object} apiKey - API Key 对象
|
||||||
|
* @returns {Array<{field: string, platform: string, name: string, accountId: string}>}
|
||||||
|
*/
|
||||||
|
getBindingDisplayNames(apiKey) {
|
||||||
|
const bindings = []
|
||||||
|
|
||||||
|
const bindingFields = [
|
||||||
|
{ field: 'claudeAccountId', platform: 'Claude' },
|
||||||
|
{ field: 'claudeConsoleAccountId', platform: 'Claude Console' },
|
||||||
|
{ field: 'geminiAccountId', platform: 'Gemini' },
|
||||||
|
{ field: 'openaiAccountId', platform: 'OpenAI' },
|
||||||
|
{ field: 'azureOpenaiAccountId', platform: 'Azure OpenAI' },
|
||||||
|
{ field: 'bedrockAccountId', platform: 'Bedrock' },
|
||||||
|
{ field: 'droidAccountId', platform: 'Droid' },
|
||||||
|
{ field: 'ccrAccountId', platform: 'CCR' }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { field, platform } of bindingFields) {
|
||||||
|
const accountId = apiKey[field]
|
||||||
|
if (accountId) {
|
||||||
|
const name = this.getAccountDisplayName(accountId, field)
|
||||||
|
bindings.push({ field, platform, name, accountId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索绑定账号
|
||||||
|
* @param {Array} apiKeys - API Key 列表
|
||||||
|
* @param {string} keyword - 搜索关键词
|
||||||
|
* @returns {Array} 匹配的 API Key 列表
|
||||||
|
*/
|
||||||
|
searchByBindingAccount(apiKeys, keyword) {
|
||||||
|
const lowerKeyword = keyword.toLowerCase().trim()
|
||||||
|
if (!lowerKeyword) {
|
||||||
|
return apiKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKeys.filter((key) => {
|
||||||
|
const bindings = this.getBindingDisplayNames(key)
|
||||||
|
|
||||||
|
// 无绑定时,匹配"共享池"
|
||||||
|
if (bindings.length === 0) {
|
||||||
|
return '共享池'.includes(lowerKeyword) || 'shared'.includes(lowerKeyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配任一绑定账户
|
||||||
|
return bindings.some((binding) => {
|
||||||
|
// 匹配账户名称
|
||||||
|
if (binding.name && binding.name.toLowerCase().includes(lowerKeyword)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 匹配平台名称
|
||||||
|
if (binding.platform.toLowerCase().includes(lowerKeyword)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 匹配账户 ID
|
||||||
|
if (binding.accountId.toLowerCase().includes(lowerKeyword)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除缓存(用于测试或强制刷新)
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.accountCache.clear()
|
||||||
|
this.groupCache.clear()
|
||||||
|
this.lastRefresh = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例导出
|
||||||
|
module.exports = new AccountNameCacheService()
|
||||||
@@ -1808,7 +1808,8 @@ const accountsLoading = ref(false)
|
|||||||
const accountSortBy = ref('name')
|
const accountSortBy = ref('name')
|
||||||
const accountsSortBy = ref('')
|
const accountsSortBy = ref('')
|
||||||
const accountsSortOrder = ref('asc')
|
const accountsSortOrder = ref('asc')
|
||||||
const apiKeys = ref([])
|
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||||
|
const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X 个API Key"
|
||||||
const accountGroups = ref([])
|
const accountGroups = ref([])
|
||||||
const groupFilter = ref('all')
|
const groupFilter = ref('all')
|
||||||
const platformFilter = ref('all')
|
const platformFilter = ref('all')
|
||||||
@@ -1858,7 +1859,8 @@ const editingExpiryAccount = ref(null)
|
|||||||
const expiryEditModalRef = ref(null)
|
const expiryEditModalRef = ref(null)
|
||||||
|
|
||||||
// 缓存状态标志
|
// 缓存状态标志
|
||||||
const apiKeysLoaded = ref(false)
|
const apiKeysLoaded = ref(false) // 用于其他功能
|
||||||
|
const bindingCountsLoaded = ref(false) // 轻量级绑定计数缓存
|
||||||
const groupsLoaded = ref(false)
|
const groupsLoaded = ref(false)
|
||||||
const groupMembersLoaded = ref(false)
|
const groupMembersLoaded = ref(false)
|
||||||
const accountGroupMap = ref(new Map()) // Map<accountId, Array<groupInfo>>
|
const accountGroupMap = ref(new Map()) // Map<accountId, Array<groupInfo>>
|
||||||
@@ -2372,8 +2374,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用缓存机制加载 API Keys 和分组数据
|
// 使用缓存机制加载绑定计数和分组数据(不再加载完整的 API Keys 数据)
|
||||||
await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)])
|
await Promise.all([loadBindingCounts(forceReload), loadAccountGroups(forceReload)])
|
||||||
|
|
||||||
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
||||||
// await loadGroupMembers(forceReload)
|
// await loadGroupMembers(forceReload)
|
||||||
@@ -2393,12 +2395,13 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
|
|
||||||
const allAccounts = []
|
const allAccounts = []
|
||||||
|
|
||||||
|
// 获取绑定计数数据
|
||||||
|
const counts = bindingCounts.value
|
||||||
|
|
||||||
if (claudeData.success) {
|
if (claudeData.success) {
|
||||||
const claudeAccounts = (claudeData.data || []).map((acc) => {
|
const claudeAccounts = (claudeData.data || []).map((acc) => {
|
||||||
// 计算每个Claude账户绑定的API Key数量
|
// 从绑定计数缓存获取数量
|
||||||
const boundApiKeysCount = apiKeys.value.filter(
|
const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0
|
||||||
(key) => key.claudeAccountId === acc.id
|
|
||||||
).length
|
|
||||||
// 后端已经包含了groupInfos,直接使用
|
// 后端已经包含了groupInfos,直接使用
|
||||||
return { ...acc, platform: 'claude', boundApiKeysCount }
|
return { ...acc, platform: 'claude', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
@@ -2407,10 +2410,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
|
|
||||||
if (claudeConsoleData.success) {
|
if (claudeConsoleData.success) {
|
||||||
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => {
|
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => {
|
||||||
// 计算每个Claude Console账户绑定的API Key数量
|
// 从绑定计数缓存获取数量
|
||||||
const boundApiKeysCount = apiKeys.value.filter(
|
const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0
|
||||||
(key) => key.claudeConsoleAccountId === acc.id
|
|
||||||
).length
|
|
||||||
// 后端已经包含了groupInfos,直接使用
|
// 后端已经包含了groupInfos,直接使用
|
||||||
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
@@ -2428,10 +2429,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
|
|
||||||
if (geminiData.success) {
|
if (geminiData.success) {
|
||||||
const geminiAccounts = (geminiData.data || []).map((acc) => {
|
const geminiAccounts = (geminiData.data || []).map((acc) => {
|
||||||
// 计算每个Gemini账户绑定的API Key数量
|
// 从绑定计数缓存获取数量
|
||||||
const boundApiKeysCount = apiKeys.value.filter(
|
const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0
|
||||||
(key) => key.geminiAccountId === acc.id
|
|
||||||
).length
|
|
||||||
// 后端已经包含了groupInfos,直接使用
|
// 后端已经包含了groupInfos,直接使用
|
||||||
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
@@ -2439,10 +2438,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
}
|
}
|
||||||
if (openaiData.success) {
|
if (openaiData.success) {
|
||||||
const openaiAccounts = (openaiData.data || []).map((acc) => {
|
const openaiAccounts = (openaiData.data || []).map((acc) => {
|
||||||
// 计算每个OpenAI账户绑定的API Key数量
|
// 从绑定计数缓存获取数量
|
||||||
const boundApiKeysCount = apiKeys.value.filter(
|
const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0
|
||||||
(key) => key.openaiAccountId === acc.id
|
|
||||||
).length
|
|
||||||
// 后端已经包含了groupInfos,直接使用
|
// 后端已经包含了groupInfos,直接使用
|
||||||
return { ...acc, platform: 'openai', boundApiKeysCount }
|
return { ...acc, platform: 'openai', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
@@ -2450,10 +2447,8 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
}
|
}
|
||||||
if (azureOpenaiData && azureOpenaiData.success) {
|
if (azureOpenaiData && azureOpenaiData.success) {
|
||||||
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => {
|
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => {
|
||||||
// 计算每个Azure OpenAI账户绑定的API Key数量
|
// 从绑定计数缓存获取数量
|
||||||
const boundApiKeysCount = apiKeys.value.filter(
|
const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0
|
||||||
(key) => key.azureOpenaiAccountId === acc.id
|
|
||||||
).length
|
|
||||||
// 后端已经包含了groupInfos,直接使用
|
// 后端已经包含了groupInfos,直接使用
|
||||||
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
|
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
@@ -2462,11 +2457,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
|
|
||||||
if (openaiResponsesData && openaiResponsesData.success) {
|
if (openaiResponsesData && openaiResponsesData.success) {
|
||||||
const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => {
|
const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => {
|
||||||
// 计算每个OpenAI-Responses账户绑定的API Key数量
|
// 从绑定计数缓存获取数量
|
||||||
// OpenAI-Responses账户使用 responses: 前缀
|
// OpenAI-Responses账户使用 responses: 前缀
|
||||||
const boundApiKeysCount = apiKeys.value.filter(
|
const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0
|
||||||
(key) => key.openaiAccountId === `responses:${acc.id}`
|
|
||||||
).length
|
|
||||||
// 后端已经包含了groupInfos,直接使用
|
// 后端已经包含了groupInfos,直接使用
|
||||||
return { ...acc, platform: 'openai-responses', boundApiKeysCount }
|
return { ...acc, platform: 'openai-responses', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
@@ -2485,10 +2478,12 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
// Droid 账户
|
// Droid 账户
|
||||||
if (droidData && droidData.success) {
|
if (droidData && droidData.success) {
|
||||||
const droidAccounts = (droidData.data || []).map((acc) => {
|
const droidAccounts = (droidData.data || []).map((acc) => {
|
||||||
|
// 从绑定计数缓存获取数量
|
||||||
|
const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
platform: 'droid',
|
platform: 'droid',
|
||||||
boundApiKeysCount: acc.boundApiKeysCount ?? 0
|
boundApiKeysCount
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
allAccounts.push(...droidAccounts)
|
allAccounts.push(...droidAccounts)
|
||||||
@@ -2497,11 +2492,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
// Gemini API 账户
|
// Gemini API 账户
|
||||||
if (geminiApiData && geminiApiData.success) {
|
if (geminiApiData && geminiApiData.success) {
|
||||||
const geminiApiAccounts = (geminiApiData.data || []).map((acc) => {
|
const geminiApiAccounts = (geminiApiData.data || []).map((acc) => {
|
||||||
// 计算每个Gemini-API账户绑定的API Key数量
|
// 从绑定计数缓存获取数量
|
||||||
// Gemini-API账户使用 api: 前缀
|
// Gemini-API账户使用 api: 前缀
|
||||||
const boundApiKeysCount = apiKeys.value.filter(
|
const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0
|
||||||
(key) => key.geminiAccountId === `api:${acc.id}`
|
|
||||||
).length
|
|
||||||
// 后端已经包含了groupInfos,直接使用
|
// 后端已经包含了groupInfos,直接使用
|
||||||
return { ...acc, platform: 'gemini-api', boundApiKeysCount }
|
return { ...acc, platform: 'gemini-api', boundApiKeysCount }
|
||||||
})
|
})
|
||||||
@@ -2620,7 +2613,25 @@ const clearSearch = () => {
|
|||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载API Keys列表(缓存版本)
|
// 加载绑定计数(轻量级接口,用于显示"绑定: X 个API Key")
|
||||||
|
const loadBindingCounts = async (forceReload = false) => {
|
||||||
|
if (!forceReload && bindingCountsLoaded.value) {
|
||||||
|
return // 使用缓存数据
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/accounts/binding-counts')
|
||||||
|
if (response.success) {
|
||||||
|
bindingCounts.value = response.data || {}
|
||||||
|
bindingCountsLoaded.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 静默处理错误,绑定计数显示为 0
|
||||||
|
bindingCounts.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载API Keys列表(保留用于其他功能,如删除账户时显示绑定信息)
|
||||||
const loadApiKeys = async (forceReload = false) => {
|
const loadApiKeys = async (forceReload = false) => {
|
||||||
if (!forceReload && apiKeysLoaded.value) {
|
if (!forceReload && apiKeysLoaded.value) {
|
||||||
return // 使用缓存数据
|
return // 使用缓存数据
|
||||||
@@ -2629,7 +2640,7 @@ const loadApiKeys = async (forceReload = false) => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/api-keys')
|
const response = await apiClient.get('/admin/api-keys')
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
apiKeys.value = response.data || []
|
apiKeys.value = response.data?.items || response.data || []
|
||||||
apiKeysLoaded.value = true
|
apiKeysLoaded.value = true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2657,6 +2668,7 @@ const loadAccountGroups = async (forceReload = false) => {
|
|||||||
// 清空缓存的函数
|
// 清空缓存的函数
|
||||||
const clearCache = () => {
|
const clearCache = () => {
|
||||||
apiKeysLoaded.value = false
|
apiKeysLoaded.value = false
|
||||||
|
bindingCountsLoaded.value = false
|
||||||
groupsLoaded.value = false
|
groupsLoaded.value = false
|
||||||
groupMembersLoaded.value = false
|
groupMembersLoaded.value = false
|
||||||
accountGroupMap.value.clear()
|
accountGroupMap.value.clear()
|
||||||
@@ -2929,8 +2941,10 @@ const deleteAccount = async (account) => {
|
|||||||
|
|
||||||
groupMembersLoaded.value = false
|
groupMembersLoaded.value = false
|
||||||
apiKeysLoaded.value = false
|
apiKeysLoaded.value = false
|
||||||
|
bindingCountsLoaded.value = false
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
loadApiKeys(true)
|
loadApiKeys(true) // 刷新完整 API Keys 列表(用于其他功能)
|
||||||
|
loadBindingCounts(true) // 刷新绑定计数
|
||||||
} else {
|
} else {
|
||||||
showToast(result.message || '删除失败', 'error')
|
showToast(result.message || '删除失败', 'error')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,6 @@
|
|||||||
icon-color="text-purple-500"
|
icon-color="text-purple-500"
|
||||||
:options="tagOptions"
|
:options="tagOptions"
|
||||||
placeholder="所有标签"
|
placeholder="所有标签"
|
||||||
@change="currentPage = 1"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="selectedTagFilter"
|
v-if="selectedTagFilter"
|
||||||
@@ -126,7 +125,6 @@
|
|||||||
icon-color="text-cyan-500"
|
icon-color="text-cyan-500"
|
||||||
:options="searchModeOptions"
|
:options="searchModeOptions"
|
||||||
placeholder="选择搜索类型"
|
placeholder="选择搜索类型"
|
||||||
@change="currentPage = 1"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="group relative flex-1">
|
<div class="group relative flex-1">
|
||||||
@@ -145,7 +143,6 @@
|
|||||||
: '搜索名称...'
|
: '搜索名称...'
|
||||||
"
|
"
|
||||||
type="text"
|
type="text"
|
||||||
@input="currentPage = 1"
|
|
||||||
/>
|
/>
|
||||||
<i class="fas fa-search absolute left-3 text-sm text-cyan-500" />
|
<i class="fas fa-search absolute left-3 text-sm text-cyan-500" />
|
||||||
<button
|
<button
|
||||||
@@ -313,19 +310,9 @@
|
|||||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[4%] min-w-[40px] cursor-pointer px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
class="w-[4%] min-w-[40px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
@click="sortApiKeys('periodCost')"
|
|
||||||
>
|
>
|
||||||
费用
|
费用
|
||||||
<i
|
|
||||||
v-if="apiKeysSortBy === 'periodCost'"
|
|
||||||
:class="[
|
|
||||||
'fas',
|
|
||||||
apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
|
||||||
'ml-1'
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[14%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
class="w-[14%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
@@ -333,34 +320,14 @@
|
|||||||
限制
|
限制
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[5%] min-w-[45px] cursor-pointer px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
class="w-[5%] min-w-[45px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
@click="sortApiKeys('periodTokens')"
|
|
||||||
>
|
>
|
||||||
Token
|
Token
|
||||||
<i
|
|
||||||
v-if="apiKeysSortBy === 'periodTokens'"
|
|
||||||
:class="[
|
|
||||||
'fas',
|
|
||||||
apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
|
||||||
'ml-1'
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[5%] min-w-[45px] cursor-pointer px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
class="w-[5%] min-w-[45px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||||
@click="sortApiKeys('periodRequests')"
|
|
||||||
>
|
>
|
||||||
请求数
|
请求数
|
||||||
<i
|
|
||||||
v-if="apiKeysSortBy === 'periodRequests'"
|
|
||||||
:class="[
|
|
||||||
'fas',
|
|
||||||
apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
|
||||||
'ml-1'
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[8%] min-w-[70px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
class="w-[8%] min-w-[70px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
@@ -577,12 +544,25 @@
|
|||||||
</td>
|
</td>
|
||||||
<!-- 费用 -->
|
<!-- 费用 -->
|
||||||
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
||||||
|
<!-- 加载中状态 -->
|
||||||
|
<template v-if="isStatsLoading(key.id)">
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<i class="fas fa-spinner fa-spin text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 已加载状态 -->
|
||||||
|
<template v-else-if="getCachedStats(key.id)">
|
||||||
<span
|
<span
|
||||||
class="font-semibold text-blue-600 dark:text-blue-400"
|
class="font-semibold text-blue-600 dark:text-blue-400"
|
||||||
style="font-size: 14px"
|
style="font-size: 14px"
|
||||||
>
|
>
|
||||||
${{ getPeriodCost(key).toFixed(2) }}
|
{{ getCachedStats(key.id).formattedCost || '$0.00' }}
|
||||||
</span>
|
</span>
|
||||||
|
</template>
|
||||||
|
<!-- 未加载状态 -->
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<!-- 限制 -->
|
<!-- 限制 -->
|
||||||
<td class="px-2 py-2" style="font-size: 12px">
|
<td class="px-2 py-2" style="font-size: 12px">
|
||||||
@@ -600,7 +580,7 @@
|
|||||||
<!-- 总费用限制进度条(无每日限制时展示) -->
|
<!-- 总费用限制进度条(无每日限制时展示) -->
|
||||||
<LimitProgressBar
|
<LimitProgressBar
|
||||||
v-else-if="key.totalCostLimit > 0"
|
v-else-if="key.totalCostLimit > 0"
|
||||||
:current="key.usage?.total?.cost || 0"
|
:current="getCachedStats(key.id)?.cost || key.usage?.total?.cost || 0"
|
||||||
label="总费用限制"
|
label="总费用限制"
|
||||||
:limit="key.totalCostLimit"
|
:limit="key.totalCostLimit"
|
||||||
type="total"
|
type="total"
|
||||||
@@ -660,26 +640,52 @@
|
|||||||
</td>
|
</td>
|
||||||
<!-- Token数量 -->
|
<!-- Token数量 -->
|
||||||
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
||||||
|
<!-- 加载中状态 -->
|
||||||
|
<template v-if="isStatsLoading(key.id)">
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<i class="fas fa-spinner fa-spin text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 已加载状态 -->
|
||||||
|
<template v-else-if="getCachedStats(key.id)">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<span
|
<span
|
||||||
class="font-medium text-purple-600 dark:text-purple-400"
|
class="font-medium text-purple-600 dark:text-purple-400"
|
||||||
style="font-size: 13px"
|
style="font-size: 13px"
|
||||||
>
|
>
|
||||||
{{ formatTokenCount(getPeriodTokens(key)) }}
|
{{ formatTokenCount(getCachedStats(key.id).tokens || 0) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 未加载状态 -->
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<!-- 请求数 -->
|
<!-- 请求数 -->
|
||||||
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
||||||
|
<!-- 加载中状态 -->
|
||||||
|
<template v-if="isStatsLoading(key.id)">
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<i class="fas fa-spinner fa-spin text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 已加载状态 -->
|
||||||
|
<template v-else-if="getCachedStats(key.id)">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<span
|
<span
|
||||||
class="font-medium text-gray-900 dark:text-gray-100"
|
class="font-medium text-gray-900 dark:text-gray-100"
|
||||||
style="font-size: 13px"
|
style="font-size: 13px"
|
||||||
>
|
>
|
||||||
{{ formatNumber(getPeriodRequests(key)) }}
|
{{ formatNumber(getCachedStats(key.id).requests || 0) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-gray-500">次</span>
|
<span class="text-xs text-gray-500">次</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 未加载状态 -->
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<!-- 最后使用 -->
|
<!-- 最后使用 -->
|
||||||
<td
|
<td
|
||||||
@@ -1264,15 +1270,37 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<!-- 请求数 - 使用缓存统计 -->
|
||||||
{{ formatNumber(key.usage?.daily?.requests || 0) }} 次
|
<template v-if="isStatsLoading(key.id)">
|
||||||
|
<p class="text-sm font-semibold text-gray-400">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
</p>
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="getCachedStats(key.id)">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatNumber(getCachedStats(key.id).requests || 0) }} 次
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-sm font-semibold text-gray-400">-</p>
|
||||||
|
</template>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">请求</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">请求</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-green-600">
|
<!-- 费用 - 使用缓存统计 -->
|
||||||
${{ (key.dailyCost || 0).toFixed(2) }}
|
<template v-if="isStatsLoading(key.id)">
|
||||||
|
<p class="text-sm font-semibold text-gray-400">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
</p>
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="getCachedStats(key.id)">
|
||||||
|
<p class="text-sm font-semibold text-green-600">
|
||||||
|
{{ getCachedStats(key.id).formattedCost || '$0.00' }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-sm font-semibold text-gray-400">-</p>
|
||||||
|
</template>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1319,7 +1347,7 @@
|
|||||||
<!-- 总费用限制(无每日限制时展示) -->
|
<!-- 总费用限制(无每日限制时展示) -->
|
||||||
<LimitProgressBar
|
<LimitProgressBar
|
||||||
v-else-if="key.totalCostLimit > 0"
|
v-else-if="key.totalCostLimit > 0"
|
||||||
:current="key.usage?.total?.cost || 0"
|
:current="getCachedStats(key.id)?.cost || key.usage?.total?.cost || 0"
|
||||||
label="总费用限制"
|
label="总费用限制"
|
||||||
:limit="key.totalCostLimit"
|
:limit="key.totalCostLimit"
|
||||||
type="total"
|
type="total"
|
||||||
@@ -1486,7 +1514,6 @@
|
|||||||
<select
|
<select
|
||||||
v-model="pageSize"
|
v-model="pageSize"
|
||||||
class="rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 transition-colors hover:border-gray-300 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:text-sm"
|
class="rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 transition-colors hover:border-gray-300 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:text-sm"
|
||||||
@change="currentPage = 1"
|
|
||||||
>
|
>
|
||||||
<option v-for="size in pageSizeOptions" :key="size" :value="size">
|
<option v-for="size in pageSizeOptions" :key="size" :value="size">
|
||||||
{{ size }}
|
{{ size }}
|
||||||
@@ -1996,9 +2023,22 @@ const timeRangeDropdownOptions = computed(() => [
|
|||||||
const activeTab = ref('active')
|
const activeTab = ref('active')
|
||||||
const deletedApiKeys = ref([])
|
const deletedApiKeys = ref([])
|
||||||
const deletedApiKeysLoading = ref(false)
|
const deletedApiKeysLoading = ref(false)
|
||||||
const apiKeysSortBy = ref('periodCost')
|
const apiKeysSortBy = ref('createdAt') // 修改默认排序为创建时间(移除费用排序支持)
|
||||||
const apiKeysSortOrder = ref('desc')
|
const apiKeysSortOrder = ref('desc')
|
||||||
const expandedApiKeys = ref({})
|
const expandedApiKeys = ref({})
|
||||||
|
|
||||||
|
// 后端分页相关状态
|
||||||
|
const serverPagination = ref({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统计数据缓存: Map<keyId, { stats, timeRange, timestamp }>
|
||||||
|
const statsCache = ref(new Map())
|
||||||
|
// 正在加载统计的 keyIds
|
||||||
|
const statsLoading = ref(new Set())
|
||||||
const apiKeyModelStats = ref({})
|
const apiKeyModelStats = ref({})
|
||||||
const apiKeyDateFilters = ref({})
|
const apiKeyDateFilters = ref({})
|
||||||
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||||
@@ -2074,148 +2114,15 @@ const renewingApiKey = ref(null)
|
|||||||
const newApiKeyData = ref(null)
|
const newApiKeyData = ref(null)
|
||||||
const batchApiKeyData = ref([])
|
const batchApiKeyData = ref([])
|
||||||
|
|
||||||
// 提取“所属账号”列直接展示的文本
|
// 计算排序后的API Keys(现在由后端处理,这里直接返回)
|
||||||
const getBindingDisplayStrings = (key) => {
|
|
||||||
const values = new Set()
|
|
||||||
|
|
||||||
const collect = (...items) => {
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (typeof item !== 'string') return
|
|
||||||
const trimmed = item.trim()
|
|
||||||
if (trimmed) {
|
|
||||||
values.add(trimmed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitize = (text) => {
|
|
||||||
if (typeof text !== 'string') return ''
|
|
||||||
return text
|
|
||||||
.replace(/^⚠️\s*/, '')
|
|
||||||
.replace(/^🔒\s*/, '')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const appendBindingRow = (label, info) => {
|
|
||||||
const infoSanitized = sanitize(info)
|
|
||||||
collect(label, info, infoSanitized)
|
|
||||||
if (infoSanitized) {
|
|
||||||
collect(`${label} ${infoSanitized}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.claudeAccountId || key.claudeConsoleAccountId) {
|
|
||||||
appendBindingRow('Claude', getClaudeBindingInfo(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.geminiAccountId) {
|
|
||||||
appendBindingRow('Gemini', getGeminiBindingInfo(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.openaiAccountId) {
|
|
||||||
appendBindingRow('OpenAI', getOpenAIBindingInfo(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.bedrockAccountId) {
|
|
||||||
appendBindingRow('Bedrock', getBedrockBindingInfo(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.droidAccountId) {
|
|
||||||
appendBindingRow('Droid', getDroidBindingInfo(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!key.claudeAccountId &&
|
|
||||||
!key.claudeConsoleAccountId &&
|
|
||||||
!key.geminiAccountId &&
|
|
||||||
!key.openaiAccountId &&
|
|
||||||
!key.bedrockAccountId &&
|
|
||||||
!key.droidAccountId
|
|
||||||
) {
|
|
||||||
collect('共享池')
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算排序后的API Keys
|
|
||||||
const sortedApiKeys = computed(() => {
|
const sortedApiKeys = computed(() => {
|
||||||
// 先进行标签筛选
|
// 后端已经处理了筛选、搜索和排序,直接返回
|
||||||
let filteredKeys = apiKeys.value
|
return apiKeys.value
|
||||||
if (selectedTagFilter.value) {
|
|
||||||
filteredKeys = apiKeys.value.filter(
|
|
||||||
(key) => key.tags && key.tags.includes(selectedTagFilter.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 然后进行搜索过滤
|
|
||||||
if (searchKeyword.value) {
|
|
||||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
|
||||||
filteredKeys = filteredKeys.filter((key) => {
|
|
||||||
if (searchMode.value === 'bindingAccount') {
|
|
||||||
const bindings = getBindingDisplayStrings(key)
|
|
||||||
if (bindings.length === 0) return false
|
|
||||||
return bindings.some((text) => text.toLowerCase().includes(keyword))
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameMatch = key.name && key.name.toLowerCase().includes(keyword)
|
|
||||||
if (isLdapEnabled.value) {
|
|
||||||
const ownerMatch =
|
|
||||||
key.ownerDisplayName && key.ownerDisplayName.toLowerCase().includes(keyword)
|
|
||||||
return nameMatch || ownerMatch
|
|
||||||
}
|
|
||||||
return nameMatch
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有排序字段,返回筛选后的结果
|
|
||||||
if (!apiKeysSortBy.value) return filteredKeys
|
|
||||||
|
|
||||||
// 排序
|
|
||||||
const sorted = [...filteredKeys].sort((a, b) => {
|
|
||||||
let aVal = a[apiKeysSortBy.value]
|
|
||||||
let bVal = b[apiKeysSortBy.value]
|
|
||||||
|
|
||||||
// 处理特殊排序字段
|
|
||||||
if (apiKeysSortBy.value === 'status') {
|
|
||||||
aVal = a.isActive ? 1 : 0
|
|
||||||
bVal = b.isActive ? 1 : 0
|
|
||||||
} else if (apiKeysSortBy.value === 'periodRequests') {
|
|
||||||
aVal = getPeriodRequests(a)
|
|
||||||
bVal = getPeriodRequests(b)
|
|
||||||
} else if (apiKeysSortBy.value === 'periodCost') {
|
|
||||||
aVal = calculatePeriodCost(a)
|
|
||||||
bVal = calculatePeriodCost(b)
|
|
||||||
} else if (apiKeysSortBy.value === 'periodTokens') {
|
|
||||||
aVal = getPeriodTokens(a)
|
|
||||||
bVal = getPeriodTokens(b)
|
|
||||||
} else if (apiKeysSortBy.value === 'dailyCost') {
|
|
||||||
aVal = a.dailyCost || 0
|
|
||||||
bVal = b.dailyCost || 0
|
|
||||||
} else if (apiKeysSortBy.value === 'totalCost') {
|
|
||||||
aVal = a.totalCost || 0
|
|
||||||
bVal = b.totalCost || 0
|
|
||||||
} else if (
|
|
||||||
apiKeysSortBy.value === 'createdAt' ||
|
|
||||||
apiKeysSortBy.value === 'expiresAt' ||
|
|
||||||
apiKeysSortBy.value === 'lastUsedAt'
|
|
||||||
) {
|
|
||||||
aVal = aVal ? new Date(aVal).getTime() : 0
|
|
||||||
bVal = bVal ? new Date(bVal).getTime() : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aVal < bVal) return apiKeysSortOrder.value === 'asc' ? -1 : 1
|
|
||||||
if (aVal > bVal) return apiKeysSortOrder.value === 'asc' ? 1 : -1
|
|
||||||
return 0
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return sorted
|
// 计算总页数(使用后端分页信息)
|
||||||
})
|
|
||||||
|
|
||||||
// 计算总页数
|
|
||||||
const totalPages = computed(() => {
|
const totalPages = computed(() => {
|
||||||
const total = sortedApiKeys.value.length
|
return serverPagination.value.totalPages || 0
|
||||||
return Math.ceil(total / pageSize.value) || 0
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算显示的页码数组
|
// 计算显示的页码数组
|
||||||
@@ -2273,11 +2180,10 @@ const showTrailingEllipsis = computed(() => {
|
|||||||
return shouldShowLastPage.value && pages[pages.length - 1] < totalPages.value - 1
|
return shouldShowLastPage.value && pages[pages.length - 1] < totalPages.value - 1
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取分页后的数据
|
// 获取分页后的数据(现在由后端处理,直接返回当前数据)
|
||||||
const paginatedApiKeys = computed(() => {
|
const paginatedApiKeys = computed(() => {
|
||||||
const start = (currentPage.value - 1) * pageSize.value
|
// 后端已经分页,直接返回
|
||||||
const end = start + pageSize.value
|
return apiKeys.value
|
||||||
return sortedApiKeys.value.slice(start, end)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载账户列表
|
// 加载账户列表
|
||||||
@@ -2397,46 +2303,169 @@ const loadAccounts = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载API Keys
|
// 加载API Keys(使用后端分页)
|
||||||
const loadApiKeys = async () => {
|
const loadApiKeys = async (clearStatsCache = true) => {
|
||||||
apiKeysLoading.value = true
|
apiKeysLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
// 清除统计缓存(刷新时)
|
||||||
|
if (clearStatsCache) {
|
||||||
|
statsCache.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
// 构建请求参数
|
// 构建请求参数
|
||||||
let params = {}
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
// 分页参数
|
||||||
|
params.set('page', currentPage.value.toString())
|
||||||
|
params.set('pageSize', pageSize.value.toString())
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
params.set('searchMode', searchMode.value)
|
||||||
|
if (searchKeyword.value) {
|
||||||
|
params.set('search', searchKeyword.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选参数
|
||||||
|
if (selectedTagFilter.value) {
|
||||||
|
params.set('tag', selectedTagFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序参数(只支持非费用字段)
|
||||||
|
const validSortFields = ['name', 'createdAt', 'expiresAt', 'lastUsedAt', 'isActive', 'status']
|
||||||
|
const effectiveSortBy = validSortFields.includes(apiKeysSortBy.value)
|
||||||
|
? apiKeysSortBy.value
|
||||||
|
: 'createdAt'
|
||||||
|
params.set('sortBy', effectiveSortBy)
|
||||||
|
params.set('sortOrder', apiKeysSortOrder.value)
|
||||||
|
|
||||||
|
// 时间范围(用于标记,不用于费用计算)
|
||||||
if (
|
if (
|
||||||
globalDateFilter.type === 'custom' &&
|
globalDateFilter.type === 'custom' &&
|
||||||
globalDateFilter.customStart &&
|
globalDateFilter.customStart &&
|
||||||
globalDateFilter.customEnd
|
globalDateFilter.customEnd
|
||||||
) {
|
) {
|
||||||
params.startDate = globalDateFilter.customStart
|
params.set('startDate', globalDateFilter.customStart)
|
||||||
params.endDate = globalDateFilter.customEnd
|
params.set('endDate', globalDateFilter.customEnd)
|
||||||
params.timeRange = 'custom'
|
params.set('timeRange', 'custom')
|
||||||
} else if (globalDateFilter.preset === 'all') {
|
} else if (globalDateFilter.preset === 'all') {
|
||||||
params.timeRange = 'all'
|
params.set('timeRange', 'all')
|
||||||
} else {
|
} else {
|
||||||
params.timeRange = globalDateFilter.preset
|
params.set('timeRange', globalDateFilter.preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryString = new URLSearchParams(params).toString()
|
const data = await apiClient.get(`/admin/api-keys?${params.toString()}`)
|
||||||
const data = await apiClient.get(`/admin/api-keys?${queryString}`)
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
apiKeys.value = data.data || []
|
// 更新数据
|
||||||
// 更新可用标签列表
|
apiKeys.value = data.data?.items || []
|
||||||
const tagsSet = new Set()
|
|
||||||
apiKeys.value.forEach((key) => {
|
// 更新分页信息
|
||||||
if (key.tags && Array.isArray(key.tags)) {
|
if (data.data?.pagination) {
|
||||||
key.tags.forEach((tag) => tagsSet.add(tag))
|
serverPagination.value = data.data.pagination
|
||||||
|
// 同步当前页码(处理页面超出范围的情况)
|
||||||
|
if (
|
||||||
|
currentPage.value > serverPagination.value.totalPages &&
|
||||||
|
serverPagination.value.totalPages > 0
|
||||||
|
) {
|
||||||
|
currentPage.value = serverPagination.value.totalPages
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
availableTags.value = Array.from(tagsSet).sort()
|
|
||||||
|
// 更新可用标签列表
|
||||||
|
if (data.data?.availableTags) {
|
||||||
|
availableTags.value = data.data.availableTags
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步加载当前页的统计数据
|
||||||
|
await loadPageStats()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('加载 API Keys 失败:', error)
|
||||||
showToast('加载 API Keys 失败', 'error')
|
showToast('加载 API Keys 失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
apiKeysLoading.value = false
|
apiKeysLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 异步加载当前页的统计数据
|
||||||
|
const loadPageStats = async () => {
|
||||||
|
const currentPageKeys = apiKeys.value
|
||||||
|
if (!currentPageKeys || currentPageKeys.length === 0) return
|
||||||
|
|
||||||
|
// 获取当前时间范围
|
||||||
|
let currentTimeRange = globalDateFilter.preset
|
||||||
|
let startDate = null
|
||||||
|
let endDate = null
|
||||||
|
|
||||||
|
if (
|
||||||
|
globalDateFilter.type === 'custom' &&
|
||||||
|
globalDateFilter.customStart &&
|
||||||
|
globalDateFilter.customEnd
|
||||||
|
) {
|
||||||
|
currentTimeRange = 'custom'
|
||||||
|
startDate = globalDateFilter.customStart
|
||||||
|
endDate = globalDateFilter.customEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选出需要加载的 keys(未缓存或时间范围变化)
|
||||||
|
const keysNeedStats = currentPageKeys.filter((key) => {
|
||||||
|
const cached = statsCache.value.get(key.id)
|
||||||
|
if (!cached) return true
|
||||||
|
if (cached.timeRange !== currentTimeRange) return true
|
||||||
|
if (currentTimeRange === 'custom') {
|
||||||
|
if (cached.startDate !== startDate || cached.endDate !== endDate) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (keysNeedStats.length === 0) return
|
||||||
|
|
||||||
|
// 标记为加载中
|
||||||
|
const keyIds = keysNeedStats.map((k) => k.id)
|
||||||
|
keyIds.forEach((id) => statsLoading.value.add(id))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody = {
|
||||||
|
keyIds,
|
||||||
|
timeRange: currentTimeRange
|
||||||
|
}
|
||||||
|
if (currentTimeRange === 'custom') {
|
||||||
|
requestBody.startDate = startDate
|
||||||
|
requestBody.endDate = endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.post('/admin/api-keys/batch-stats', requestBody)
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 更新缓存
|
||||||
|
for (const [keyId, stats] of Object.entries(response.data)) {
|
||||||
|
statsCache.value.set(keyId, {
|
||||||
|
stats,
|
||||||
|
timeRange: currentTimeRange,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载统计数据失败:', error)
|
||||||
|
// 不显示 toast,避免打扰用户
|
||||||
|
} finally {
|
||||||
|
keyIds.forEach((id) => statsLoading.value.delete(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存的统计数据
|
||||||
|
const getCachedStats = (keyId) => {
|
||||||
|
const cached = statsCache.value.get(keyId)
|
||||||
|
return cached?.stats || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否正在加载统计
|
||||||
|
const isStatsLoading = (keyId) => {
|
||||||
|
return statsLoading.value.has(keyId)
|
||||||
|
}
|
||||||
|
|
||||||
// 加载已删除的API Keys
|
// 加载已删除的API Keys
|
||||||
const loadDeletedApiKeys = async () => {
|
const loadDeletedApiKeys = async () => {
|
||||||
activeTab.value = 'deleted'
|
activeTab.value = 'deleted'
|
||||||
@@ -4198,21 +4227,45 @@ watch([selectedTagFilter, apiKeyStatsTimeRange], () => {
|
|||||||
updateSelectAllState()
|
updateSelectAllState()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听搜索关键词变化,只重置分页,保持选中状态
|
// 搜索防抖定时器
|
||||||
|
let searchDebounceTimer = null
|
||||||
|
|
||||||
|
// 监听搜索关键词变化,使用防抖重新加载数据
|
||||||
watch(searchKeyword, () => {
|
watch(searchKeyword, () => {
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
}
|
||||||
|
// 设置防抖(300ms)
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
// 不清空选中状态,允许跨搜索保持勾选
|
loadApiKeys(false) // 不清除统计缓存
|
||||||
updateSelectAllState()
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听搜索模式变化,重置分页并更新选中状态
|
// 监听搜索模式变化,重新加载数据
|
||||||
watch(searchMode, () => {
|
watch(searchMode, () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
updateSelectAllState()
|
loadApiKeys(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听分页变化,更新全选状态
|
// 监听标签筛选变化,重新加载数据
|
||||||
watch([currentPage, pageSize], () => {
|
watch(selectedTagFilter, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadApiKeys(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听排序变化,重新加载数据
|
||||||
|
watch([apiKeysSortBy, apiKeysSortOrder], () => {
|
||||||
|
loadApiKeys(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听分页变化,重新加载数据
|
||||||
|
watch([currentPage, pageSize], ([newPage, newPageSize], [oldPage, oldPageSize]) => {
|
||||||
|
// 只有页码或每页数量真正变化时才重新加载
|
||||||
|
if (newPage !== oldPage || newPageSize !== oldPageSize) {
|
||||||
|
loadApiKeys(false)
|
||||||
|
}
|
||||||
updateSelectAllState()
|
updateSelectAllState()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user