diff --git a/src/models/redis.js b/src/models/redis.js index e81c89ce..97bf0dec 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -166,6 +166,224 @@ class RedisClient { return apiKeys } + /** + * 使用 SCAN 获取所有 API Key ID(避免 KEYS 命令阻塞) + * @returns {Promise} 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} 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(性能优化) async findApiKeyByHash(hashedKey) { // 使用反向映射表:hash -> keyId diff --git a/src/routes/admin.js b/src/routes/admin.js index 78fc9767..09198d5b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -193,323 +193,88 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => // 获取所有API Keys router.get('/api-keys', authenticateAdmin, async (req, res) => { try { - const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom - const apiKeys = await apiKeyService.getAllApiKeys() + const { + // 分页参数 + 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信息 const userService = require('../services/userService') - // 根据时间范围计算查询模式 - const now = new Date() - const searchPatterns = [] + // 使用优化的分页方法获取数据 + let result = await redis.getApiKeysPaginated({ + page: pageNum, + pageSize: pageSizeNum, + searchMode, + search: searchMode === 'apiKey' ? search : '', // apiKey 模式的搜索在 redis 层处理 + tag, + isActive, + sortBy: validSortBy, + sortOrder: validSortOrder + }) - if (timeRange === 'custom' && startDate && endDate) { - // 自定义日期范围 - const redisClient = require('../models/redis') - const start = new Date(startDate) - const end = new Date(endDate) + // 如果是绑定账号搜索模式,需要在这里处理 + if (searchMode === 'bindingAccount' && search) { + const accountNameCacheService = require('../services/accountNameCacheService') + await accountNameCacheService.refreshIfNeeded() - // 确保日期范围有效 - if (start > end) { - return res.status(400).json({ error: 'Start date must be before or equal to end date' }) - } + // 获取所有数据进行绑定账号搜索 + const allResult = await redis.getApiKeysPaginated({ + page: 1, + pageSize: 10000, // 获取所有数据 + searchMode: 'apiKey', + search: '', + tag, + isActive, + sortBy: validSortBy, + sortOrder: validSortOrder + }) - // 限制最大范围为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 filteredKeys = accountNameCacheService.searchByBindingAccount(allResult.items, search) - // 生成日期范围内每天的搜索模式 - 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}`) - } + // 重新分页 + const total = filteredKeys.length + const totalPages = Math.ceil(total / pageSizeNum) || 1 + const validPage = Math.min(Math.max(1, pageNum), totalPages) + const start = (validPage - 1) * pageSizeNum + const items = filteredKeys.slice(start, start + pageSizeNum) - // 为每个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 += - 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 - } - } - - let totalCost = 0 - - // 计算每个模型的费用 - for (const [model, stats] of modelStatsMap) { - const usage = { - input_tokens: stats.inputTokens, - output_tokens: stats.outputTokens, - cache_creation_input_tokens: stats.cacheCreateTokens, - cache_read_input_tokens: stats.cacheReadTokens - } - - 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 - 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 usage = { - input_tokens: stats.inputTokens, - output_tokens: stats.outputTokens, - cache_creation_input_tokens: stats.cacheCreateTokens, - cache_read_input_tokens: stats.cacheReadTokens - } - - const costResult = CostCalculator.calculateCost(usage, model) - totalCost += costResult.costs.total - } - - // 如果没有模型数据,使用临时统计数据计算 - if (modelStatsMap.size === 0 && tempUsage.tokens > 0) { - const usage = { - input_tokens: tempUsage.inputTokens, - output_tokens: tempUsage.outputTokens, - 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] + result = { + items, + pagination: { + page: validPage, + pageSize: pageSizeNum, + total, + totalPages + }, + availableTags: allResult.availableTags } } // 为每个API Key添加owner的displayName - for (const apiKey of apiKeys) { - // 如果API Key有关联的用户ID,获取用户信息 + for (const apiKey of result.items) { if (apiKey.userId) { try { const user = await userService.getUserById(apiKey.userId, false) @@ -523,13 +288,27 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { apiKey.ownerDisplayName = 'Unknown User' } } else { - // 如果没有userId,使用createdBy字段或默认为Admin apiKey.ownerDisplayName = 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) { logger.error('❌ Failed to get API keys:', error) 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 router.post('/api-keys', authenticateAdmin, async (req, res) => { try { diff --git a/src/services/accountNameCacheService.js b/src/services/accountNameCacheService.js new file mode 100644 index 00000000..564c8080 --- /dev/null +++ b/src/services/accountNameCacheService.js @@ -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() diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index f472d39a..39bc5773 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1808,7 +1808,8 @@ const accountsLoading = ref(false) const accountSortBy = ref('name') const accountsSortBy = ref('') const accountsSortOrder = ref('asc') -const apiKeys = ref([]) +const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息) +const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X 个API Key" const accountGroups = ref([]) const groupFilter = ref('all') const platformFilter = ref('all') @@ -1858,7 +1859,8 @@ const editingExpiryAccount = ref(null) const expiryEditModalRef = ref(null) // 缓存状态标志 -const apiKeysLoaded = ref(false) +const apiKeysLoaded = ref(false) // 用于其他功能 +const bindingCountsLoaded = ref(false) // 轻量级绑定计数缓存 const groupsLoaded = ref(false) const groupMembersLoaded = ref(false) const accountGroupMap = ref(new Map()) // Map> @@ -2372,8 +2374,8 @@ const loadAccounts = async (forceReload = false) => { } } - // 使用缓存机制加载 API Keys 和分组数据 - await Promise.all([loadApiKeys(forceReload), loadAccountGroups(forceReload)]) + // 使用缓存机制加载绑定计数和分组数据(不再加载完整的 API Keys 数据) + await Promise.all([loadBindingCounts(forceReload), loadAccountGroups(forceReload)]) // 后端账户API已经包含分组信息,不需要单独加载分组成员关系 // await loadGroupMembers(forceReload) @@ -2393,12 +2395,13 @@ const loadAccounts = async (forceReload = false) => { const allAccounts = [] + // 获取绑定计数数据 + const counts = bindingCounts.value + if (claudeData.success) { const claudeAccounts = (claudeData.data || []).map((acc) => { - // 计算每个Claude账户绑定的API Key数量 - const boundApiKeysCount = apiKeys.value.filter( - (key) => key.claudeAccountId === acc.id - ).length + // 从绑定计数缓存获取数量 + const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0 // 后端已经包含了groupInfos,直接使用 return { ...acc, platform: 'claude', boundApiKeysCount } }) @@ -2407,10 +2410,8 @@ const loadAccounts = async (forceReload = false) => { if (claudeConsoleData.success) { const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => { - // 计算每个Claude Console账户绑定的API Key数量 - const boundApiKeysCount = apiKeys.value.filter( - (key) => key.claudeConsoleAccountId === acc.id - ).length + // 从绑定计数缓存获取数量 + const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0 // 后端已经包含了groupInfos,直接使用 return { ...acc, platform: 'claude-console', boundApiKeysCount } }) @@ -2428,10 +2429,8 @@ const loadAccounts = async (forceReload = false) => { if (geminiData.success) { const geminiAccounts = (geminiData.data || []).map((acc) => { - // 计算每个Gemini账户绑定的API Key数量 - const boundApiKeysCount = apiKeys.value.filter( - (key) => key.geminiAccountId === acc.id - ).length + // 从绑定计数缓存获取数量 + const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0 // 后端已经包含了groupInfos,直接使用 return { ...acc, platform: 'gemini', boundApiKeysCount } }) @@ -2439,10 +2438,8 @@ const loadAccounts = async (forceReload = false) => { } if (openaiData.success) { const openaiAccounts = (openaiData.data || []).map((acc) => { - // 计算每个OpenAI账户绑定的API Key数量 - const boundApiKeysCount = apiKeys.value.filter( - (key) => key.openaiAccountId === acc.id - ).length + // 从绑定计数缓存获取数量 + const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0 // 后端已经包含了groupInfos,直接使用 return { ...acc, platform: 'openai', boundApiKeysCount } }) @@ -2450,10 +2447,8 @@ const loadAccounts = async (forceReload = false) => { } if (azureOpenaiData && azureOpenaiData.success) { const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => { - // 计算每个Azure OpenAI账户绑定的API Key数量 - const boundApiKeysCount = apiKeys.value.filter( - (key) => key.azureOpenaiAccountId === acc.id - ).length + // 从绑定计数缓存获取数量 + const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0 // 后端已经包含了groupInfos,直接使用 return { ...acc, platform: 'azure_openai', boundApiKeysCount } }) @@ -2462,11 +2457,9 @@ const loadAccounts = async (forceReload = false) => { if (openaiResponsesData && openaiResponsesData.success) { const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => { - // 计算每个OpenAI-Responses账户绑定的API Key数量 + // 从绑定计数缓存获取数量 // OpenAI-Responses账户使用 responses: 前缀 - const boundApiKeysCount = apiKeys.value.filter( - (key) => key.openaiAccountId === `responses:${acc.id}` - ).length + const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0 // 后端已经包含了groupInfos,直接使用 return { ...acc, platform: 'openai-responses', boundApiKeysCount } }) @@ -2485,10 +2478,12 @@ const loadAccounts = async (forceReload = false) => { // Droid 账户 if (droidData && droidData.success) { const droidAccounts = (droidData.data || []).map((acc) => { + // 从绑定计数缓存获取数量 + const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0 return { ...acc, platform: 'droid', - boundApiKeysCount: acc.boundApiKeysCount ?? 0 + boundApiKeysCount } }) allAccounts.push(...droidAccounts) @@ -2497,11 +2492,9 @@ const loadAccounts = async (forceReload = false) => { // Gemini API 账户 if (geminiApiData && geminiApiData.success) { const geminiApiAccounts = (geminiApiData.data || []).map((acc) => { - // 计算每个Gemini-API账户绑定的API Key数量 + // 从绑定计数缓存获取数量 // Gemini-API账户使用 api: 前缀 - const boundApiKeysCount = apiKeys.value.filter( - (key) => key.geminiAccountId === `api:${acc.id}` - ).length + const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0 // 后端已经包含了groupInfos,直接使用 return { ...acc, platform: 'gemini-api', boundApiKeysCount } }) @@ -2620,7 +2613,25 @@ const clearSearch = () => { 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) => { if (!forceReload && apiKeysLoaded.value) { return // 使用缓存数据 @@ -2629,7 +2640,7 @@ const loadApiKeys = async (forceReload = false) => { try { const response = await apiClient.get('/admin/api-keys') if (response.success) { - apiKeys.value = response.data || [] + apiKeys.value = response.data?.items || response.data || [] apiKeysLoaded.value = true } } catch (error) { @@ -2657,6 +2668,7 @@ const loadAccountGroups = async (forceReload = false) => { // 清空缓存的函数 const clearCache = () => { apiKeysLoaded.value = false + bindingCountsLoaded.value = false groupsLoaded.value = false groupMembersLoaded.value = false accountGroupMap.value.clear() @@ -2929,8 +2941,10 @@ const deleteAccount = async (account) => { groupMembersLoaded.value = false apiKeysLoaded.value = false + bindingCountsLoaded.value = false loadAccounts() - loadApiKeys(true) + loadApiKeys(true) // 刷新完整 API Keys 列表(用于其他功能) + loadBindingCounts(true) // 刷新绑定计数 } else { showToast(result.message || '删除失败', 'error') } diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 20db79a2..74c5b7b5 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -106,7 +106,6 @@ icon-color="text-purple-500" :options="tagOptions" placeholder="所有标签" - @change="currentPage = 1" />
@@ -145,7 +143,6 @@ : '搜索名称...' " type="text" - @input="currentPage = 1" />