From f5968e518ebd8bc7a6ee608ff993705d301cdd16 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 18 Jul 2025 23:49:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E5=BC=B9=E7=AA=97=E4=BD=93=E9=AA=8C=E5=92=8C?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E6=9D=A1=E7=BE=8E=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复API Key创建/编辑弹窗和账户信息修改弹窗在低高度屏幕上被遮挡的问题 - 为所有弹窗添加自适应高度支持,最大高度限制为90vh - 美化Claude账户弹窗的滚动条样式,使用紫蓝渐变色与主题保持一致 - 添加响应式适配,移动设备上弹窗高度调整为85vh - 优化滚动条交互体验,支持悬停和激活状态 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/app.js | 3 +- src/models/redis.js | 39 ++- src/routes/admin.js | 302 ++++++++++++++++- src/routes/web.js | 6 +- src/services/claudeAccountService.js | 217 ++++++++++-- src/services/claudeRelayService.js | 55 +++- web/admin/app.js | 473 +++++++++++++++++++++++++-- web/admin/index.html | 108 ++++-- web/admin/style.css | 41 +++ 9 files changed, 1148 insertions(+), 96 deletions(-) diff --git a/src/app.js b/src/app.js index 80234412..754194f0 100644 --- a/src/app.js +++ b/src/app.js @@ -270,8 +270,7 @@ class Application { logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`); }); - // 设置服务器超时时间,与代理超时时间一致 - const serverTimeout = config.proxy.timeout || 300000; // 默认5分钟 + const serverTimeout = 600000; // 默认10分钟 this.server.timeout = serverTimeout; this.server.keepAliveTimeout = serverTimeout + 5000; // keepAlive 稍长一点 logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout/1000}s)`); diff --git a/src/models/redis.js b/src/models/redis.js index 9d6655f2..2e324e49 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -139,18 +139,24 @@ class RedisClient { // 📊 使用统计相关操作(支持缓存token统计和模型信息) async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { const key = `usage:${keyId}`; - const today = new Date().toISOString().split('T')[0]; - const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; + const now = new Date(); + const today = now.toISOString().split('T')[0]; + const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(now.getHours()).padStart(2, '0')}`; // 新增小时级别 + const daily = `usage:daily:${keyId}:${today}`; const monthly = `usage:monthly:${keyId}:${currentMonth}`; + const hourly = `usage:hourly:${keyId}:${currentHour}`; // 新增小时级别key // 按模型统计的键 const modelDaily = `usage:model:daily:${model}:${today}`; const modelMonthly = `usage:model:monthly:${model}:${currentMonth}`; + const modelHourly = `usage:model:hourly:${model}:${currentHour}`; // 新增模型小时级别 // API Key级别的模型统计 const keyModelDaily = `usage:${keyId}:model:daily:${model}:${today}`; const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`; + const keyModelHourly = `usage:${keyId}:model:hourly:${model}:${currentHour}`; // 新增API Key模型小时级别 // 智能处理输入输出token分配 const finalInputTokens = inputTokens || 0; @@ -218,13 +224,40 @@ class RedisClient { this.client.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens), this.client.hincrby(keyModelMonthly, 'allTokens', totalTokens), this.client.hincrby(keyModelMonthly, 'requests', 1), + + // 小时级别统计 + this.client.hincrby(hourly, 'tokens', coreTokens), + this.client.hincrby(hourly, 'inputTokens', finalInputTokens), + this.client.hincrby(hourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(hourly, 'allTokens', totalTokens), + this.client.hincrby(hourly, 'requests', 1), + // 按模型统计 - 每小时 + this.client.hincrby(modelHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(modelHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(modelHourly, 'allTokens', totalTokens), + this.client.hincrby(modelHourly, 'requests', 1), + // API Key级别的模型统计 - 每小时 + this.client.hincrby(keyModelHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(keyModelHourly, 'allTokens', totalTokens), + this.client.hincrby(keyModelHourly, 'requests', 1), + // 设置过期时间 this.client.expire(daily, 86400 * 32), // 32天过期 this.client.expire(monthly, 86400 * 365), // 1年过期 + this.client.expire(hourly, 86400 * 7), // 小时统计7天过期 this.client.expire(modelDaily, 86400 * 32), // 模型每日统计32天过期 this.client.expire(modelMonthly, 86400 * 365), // 模型每月统计1年过期 + this.client.expire(modelHourly, 86400 * 7), // 模型小时统计7天过期 this.client.expire(keyModelDaily, 86400 * 32), // API Key模型每日统计32天过期 - this.client.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期 + this.client.expire(keyModelMonthly, 86400 * 365), // API Key模型每月统计1年过期 + this.client.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期 ]); } diff --git a/src/routes/admin.js b/src/routes/admin.js index 24944ed6..703162ba 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -351,6 +351,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { const activeApiKeys = apiKeys.filter(key => key.isActive).length; const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length; + const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length; const dashboard = { overview: { @@ -358,6 +359,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { activeApiKeys, totalClaudeAccounts: accounts.length, activeClaudeAccounts: activeAccounts, + rateLimitedClaudeAccounts: rateLimitedAccounts, totalTokensUsed, totalRequestsUsed, totalInputTokensUsed, @@ -528,22 +530,140 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => { // 获取使用趋势数据 router.get('/usage-trend', authenticateAdmin, async (req, res) => { try { - const { days = 7 } = req.query; - const daysCount = parseInt(days) || 7; + const { days = 7, granularity = 'day', startDate, endDate } = req.query; const client = redis.getClientSafe(); const trendData = []; - const today = new Date(); - // 获取过去N天的数据 - for (let i = 0; i < daysCount; i++) { - const date = new Date(today); - date.setDate(date.getDate() - i); - const dateStr = date.toISOString().split('T')[0]; + if (granularity === 'hour') { + // 小时粒度统计 + let startTime, endTime; - // 汇总当天所有API Key的使用数据 - const pattern = `usage:daily:*:${dateStr}`; - const keys = await client.keys(pattern); + if (startDate && endDate) { + // 使用自定义时间范围 + startTime = new Date(startDate); + endTime = new Date(endDate); + } else { + // 默认最近24小时 + endTime = new Date(); + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); + } + + // 确保时间范围不超过24小时 + const timeDiff = endTime - startTime; + if (timeDiff > 24 * 60 * 60 * 1000) { + return res.status(400).json({ + error: '小时粒度查询时间范围不能超过24小时' + }); + } + + // 按小时遍历 + const currentHour = new Date(startTime); + currentHour.setMinutes(0, 0, 0); + + while (currentHour <= endTime) { + const dateStr = currentHour.toISOString().split('T')[0]; + const hour = String(currentHour.getHours()).padStart(2, '0'); + const hourKey = `${dateStr}:${hour}`; + + // 获取当前小时的模型统计数据 + const modelPattern = `usage:model:hourly:*:${hourKey}`; + const modelKeys = await client.keys(modelPattern); + + let hourInputTokens = 0; + let hourOutputTokens = 0; + let hourRequests = 0; + let hourCacheCreateTokens = 0; + let hourCacheReadTokens = 0; + let hourCost = 0; + + for (const modelKey of modelKeys) { + const modelMatch = modelKey.match(/usage:model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/); + if (!modelMatch) continue; + + const model = modelMatch[1]; + const data = await client.hgetall(modelKey); + + if (data && Object.keys(data).length > 0) { + const modelInputTokens = parseInt(data.inputTokens) || 0; + const modelOutputTokens = parseInt(data.outputTokens) || 0; + const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const modelRequests = parseInt(data.requests) || 0; + + hourInputTokens += modelInputTokens; + hourOutputTokens += modelOutputTokens; + hourCacheCreateTokens += modelCacheCreateTokens; + hourCacheReadTokens += modelCacheReadTokens; + hourRequests += modelRequests; + + const modelUsage = { + input_tokens: modelInputTokens, + output_tokens: modelOutputTokens, + cache_creation_input_tokens: modelCacheCreateTokens, + cache_read_input_tokens: modelCacheReadTokens + }; + const modelCostResult = CostCalculator.calculateCost(modelUsage, model); + hourCost += modelCostResult.costs.total; + } + } + + // 如果没有模型级别的数据,尝试API Key级别的数据 + if (modelKeys.length === 0) { + const pattern = `usage:hourly:*:${hourKey}`; + const keys = await client.keys(pattern); + + for (const key of keys) { + const data = await client.hgetall(key); + if (data) { + hourInputTokens += parseInt(data.inputTokens) || 0; + hourOutputTokens += parseInt(data.outputTokens) || 0; + hourRequests += parseInt(data.requests) || 0; + hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; + hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0; + } + } + + const usage = { + input_tokens: hourInputTokens, + output_tokens: hourOutputTokens, + cache_creation_input_tokens: hourCacheCreateTokens, + cache_read_input_tokens: hourCacheReadTokens + }; + const costResult = CostCalculator.calculateCost(usage, 'unknown'); + hourCost = costResult.costs.total; + } + + trendData.push({ + date: hourKey, + hour: currentHour.toISOString(), + inputTokens: hourInputTokens, + outputTokens: hourOutputTokens, + requests: hourRequests, + cacheCreateTokens: hourCacheCreateTokens, + cacheReadTokens: hourCacheReadTokens, + totalTokens: hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens, + cost: hourCost + }); + + // 移到下一个小时 + currentHour.setHours(currentHour.getHours() + 1); + } + + } else { + // 天粒度统计(保持原有逻辑) + const daysCount = parseInt(days) || 7; + const today = new Date(); + + // 获取过去N天的数据 + for (let i = 0; i < daysCount; i++) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + + // 汇总当天所有API Key的使用数据 + const pattern = `usage:daily:*:${dateStr}`; + const keys = await client.keys(pattern); let dayInputTokens = 0; let dayOutputTokens = 0; @@ -553,7 +673,7 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => { let dayCost = 0; // 按模型统计使用量 - const modelUsageMap = new Map(); + // const modelUsageMap = new Map(); // 获取当天所有模型的使用数据 const modelPattern = `usage:model:daily:*:${dateStr}`; @@ -630,10 +750,16 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => { }); } - // 按日期正序排列 - trendData.sort((a, b) => new Date(a.date) - new Date(b.date)); + } - res.json({ success: true, data: trendData }); + // 按日期正序排列 + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)); + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)); + } + + res.json({ success: true, data: trendData, granularity }); } catch (error) { logger.error('❌ Failed to get usage trend:', error); res.status(500).json({ error: 'Failed to get usage trend', message: error.message }); @@ -833,6 +959,152 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) = }); +// 获取按API Key分组的使用趋势 +router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { + try { + const { granularity = 'day', days = 7, startDate, endDate } = req.query; + + logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`); + + const client = redis.getClientSafe(); + const trendData = []; + + // 获取所有API Keys + const apiKeys = await apiKeyService.getAllApiKeys(); + const apiKeyMap = new Map(apiKeys.map(key => [key.id, key])); + + if (granularity === 'hour') { + // 小时粒度统计 + let endTime, startTime; + + if (startDate && endDate) { + // 自定义时间范围 + startTime = new Date(startDate); + endTime = new Date(endDate); + } else { + // 默认近24小时 + endTime = new Date(); + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); + } + + // 按小时遍历 + const currentHour = new Date(startTime); + currentHour.setMinutes(0, 0, 0); + + while (currentHour <= endTime) { + const hourKey = currentHour.toISOString().split(':')[0].replace('T', ':'); + + // 获取这个小时所有API Key的数据 + const pattern = `usage:hourly:*:${hourKey}`; + const keys = await client.keys(pattern); + + const hourData = { + hour: currentHour.toISOString(), + apiKeys: {} + }; + + for (const key of keys) { + const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/); + if (!match) continue; + + const apiKeyId = match[1]; + const data = await client.hgetall(key); + + if (data && apiKeyMap.has(apiKeyId)) { + const totalTokens = (parseInt(data.inputTokens) || 0) + + (parseInt(data.outputTokens) || 0) + + (parseInt(data.cacheCreateTokens) || 0) + + (parseInt(data.cacheReadTokens) || 0); + + hourData.apiKeys[apiKeyId] = { + name: apiKeyMap.get(apiKeyId).name, + tokens: totalTokens + }; + } + } + + trendData.push(hourData); + currentHour.setHours(currentHour.getHours() + 1); + } + + } else { + // 天粒度统计 + const daysCount = parseInt(days) || 7; + const today = new Date(); + + // 获取过去N天的数据 + for (let i = 0; i < daysCount; i++) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + + // 获取这一天所有API Key的数据 + const pattern = `usage:daily:*:${dateStr}`; + const keys = await client.keys(pattern); + + const dayData = { + date: dateStr, + apiKeys: {} + }; + + for (const key of keys) { + const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/); + if (!match) continue; + + const apiKeyId = match[1]; + const data = await client.hgetall(key); + + if (data && apiKeyMap.has(apiKeyId)) { + const totalTokens = (parseInt(data.inputTokens) || 0) + + (parseInt(data.outputTokens) || 0) + + (parseInt(data.cacheCreateTokens) || 0) + + (parseInt(data.cacheReadTokens) || 0); + + dayData.apiKeys[apiKeyId] = { + name: apiKeyMap.get(apiKeyId).name, + tokens: totalTokens + }; + } + } + + trendData.push(dayData); + } + } + + // 按时间正序排列 + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)); + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)); + } + + // 计算每个API Key的总token数,用于排序 + const apiKeyTotals = new Map(); + for (const point of trendData) { + for (const [apiKeyId, data] of Object.entries(point.apiKeys)) { + apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens); + } + } + + // 获取前10个使用量最多的API Key + const topApiKeys = Array.from(apiKeyTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([apiKeyId]) => apiKeyId); + + res.json({ + success: true, + data: trendData, + granularity, + topApiKeys, + totalApiKeys: apiKeyTotals.size + }); + } catch (error) { + logger.error('❌ Failed to get API keys usage trend:', error); + res.status(500).json({ error: 'Failed to get API keys usage trend', message: error.message }); + } +}); + // 计算总体使用费用 router.get('/usage-costs', authenticateAdmin, async (req, res) => { try { diff --git a/src/routes/web.js b/src/routes/web.js index ac2dbbc6..4e6eba68 100644 --- a/src/routes/web.js +++ b/src/routes/web.js @@ -217,7 +217,7 @@ router.post('/auth/change-password', async (req, res) => { try { const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')); - const oldData = { ...initData }; // 备份旧数据 + // const oldData = { ...initData }; // 备份旧数据 // 更新 init.json initData.adminUsername = updatedUsername; @@ -252,12 +252,12 @@ router.post('/auth/change-password', async (req, res) => { // 清除当前会话(强制用户重新登录) await redis.deleteSession(token); - logger.success(`🔐 Admin password changed successfully for user: ${updatedAdminData.username}`); + logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`); res.json({ success: true, message: 'Password changed successfully. Please login again.', - newUsername: updatedAdminData.username + newUsername: updatedUsername }); } catch (error) { diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 7dce5925..1af25b2a 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -228,22 +228,35 @@ class ClaudeAccountService { try { const accounts = await redis.getAllClaudeAccounts(); - // 处理返回数据,移除敏感信息 - return accounts.map(account => ({ - id: account.id, - name: account.name, - description: account.description, - email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '', - isActive: account.isActive === 'true', - proxy: account.proxy ? JSON.parse(account.proxy) : null, - status: account.status, - errorMessage: account.errorMessage, - accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 - createdAt: account.createdAt, - lastUsedAt: account.lastUsedAt, - lastRefreshAt: account.lastRefreshAt, - expiresAt: account.expiresAt + // 处理返回数据,移除敏感信息并添加限流状态 + const processedAccounts = await Promise.all(accounts.map(async account => { + // 获取限流状态信息 + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id); + + return { + id: account.id, + name: account.name, + description: account.description, + email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '', + isActive: account.isActive === 'true', + proxy: account.proxy ? JSON.parse(account.proxy) : null, + status: account.status, + errorMessage: account.errorMessage, + accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 + createdAt: account.createdAt, + lastUsedAt: account.lastUsedAt, + lastRefreshAt: account.lastRefreshAt, + expiresAt: account.expiresAt, + // 添加限流状态信息 + rateLimitStatus: rateLimitInfo ? { + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } : null + }; })); + + return processedAccounts; } catch (error) { logger.error('❌ Failed to get Claude accounts:', error); throw error; @@ -405,8 +418,15 @@ class ClaudeAccountService { // 验证映射的账户是否仍然在共享池中且可用 const mappedAccount = sharedAccounts.find(acc => acc.id === mappedAccountId); if (mappedAccount) { - logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`); - return mappedAccountId; + // 如果映射的账户被限流了,删除映射并重新选择 + const isRateLimited = await this.isAccountRateLimited(mappedAccountId); + if (isRateLimited) { + logger.warn(`⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account`); + await redis.deleteSessionAccountMapping(sessionHash); + } else { + logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`); + return mappedAccountId; + } } else { logger.warn(`⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account`); // 清理无效的映射 @@ -415,21 +435,54 @@ class ClaudeAccountService { } } - // 从共享池选择账户(负载均衡) - const sortedAccounts = sharedAccounts.sort((a, b) => { - const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime(); - const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime(); - return bLastRefresh - aLastRefresh; - }); - const selectedAccountId = sortedAccounts[0].id; + // 将账户分为限流和非限流两组 + const nonRateLimitedAccounts = []; + const rateLimitedAccounts = []; + + for (const account of sharedAccounts) { + const isRateLimited = await this.isAccountRateLimited(account.id); + if (isRateLimited) { + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id); + account._rateLimitInfo = rateLimitInfo; // 临时存储限流信息 + rateLimitedAccounts.push(account); + } else { + nonRateLimitedAccounts.push(account); + } + } + + // 优先从非限流账户中选择 + let candidateAccounts = nonRateLimitedAccounts; + + // 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先) + if (candidateAccounts.length === 0) { + logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool'); + candidateAccounts = rateLimitedAccounts.sort((a, b) => { + const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime(); + const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime(); + return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先 + }); + } else { + // 非限流账户按最近刷新时间排序 + candidateAccounts = candidateAccounts.sort((a, b) => { + const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime(); + const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime(); + return bLastRefresh - aLastRefresh; + }); + } + + if (candidateAccounts.length === 0) { + throw new Error('No available shared Claude accounts'); + } + + const selectedAccountId = candidateAccounts[0].id; // 如果有会话哈希,建立新的映射 if (sessionHash) { await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期 - logger.info(`🎯 Created new sticky session mapping for shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`); + logger.info(`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`); } - logger.info(`🎯 Selected shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`); + logger.info(`🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`); return selectedAccountId; } catch (error) { logger.error('❌ Failed to select account for API key:', error); @@ -570,6 +623,118 @@ class ClaudeAccountService { return 0; } } + + // 🚫 标记账号为限流状态 + async markAccountRateLimited(accountId, sessionHash = null) { + try { + const accountData = await redis.getClaudeAccount(accountId); + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found'); + } + + // 设置限流状态和时间 + accountData.rateLimitedAt = new Date().toISOString(); + accountData.rateLimitStatus = 'limited'; + await redis.setClaudeAccount(accountId, accountData); + + // 如果有会话哈希,删除粘性会话映射 + if (sessionHash) { + await redis.deleteSessionAccountMapping(sessionHash); + logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`); + } + + logger.warn(`🚫 Account marked as rate limited: ${accountData.name} (${accountId})`); + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error); + throw error; + } + } + + // ✅ 移除账号的限流状态 + async removeAccountRateLimit(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId); + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found'); + } + + // 清除限流状态 + delete accountData.rateLimitedAt; + delete accountData.rateLimitStatus; + await redis.setClaudeAccount(accountId, accountData); + + logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`); + return { success: true }; + } catch (error) { + logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error); + throw error; + } + } + + // 🔍 检查账号是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId); + if (!accountData || Object.keys(accountData).length === 0) { + return false; + } + + // 检查是否有限流状态 + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt); + const now = new Date(); + const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60); + + // 如果限流超过1小时,自动解除 + if (hoursSinceRateLimit >= 1) { + await this.removeAccountRateLimit(accountId); + return false; + } + + return true; + } + + return false; + } catch (error) { + logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error); + return false; + } + } + + // 📊 获取账号的限流信息 + async getAccountRateLimitInfo(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId); + if (!accountData || Object.keys(accountData).length === 0) { + return null; + } + + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt); + const now = new Date(); + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)); + const minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit); + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: accountData.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining + }; + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0 + }; + } catch (error) { + logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error); + return null; + } + } } module.exports = new ClaudeAccountService(); \ No newline at end of file diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 7d144dcf..5161b1cd 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -72,6 +72,35 @@ class ClaudeRelayService { clientResponse.removeListener('close', handleClientDisconnect); } + // 检查响应是否为限流错误 + if (response.statusCode !== 200 && response.statusCode !== 201) { + let isRateLimited = false; + try { + const responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body; + if (responseBody && responseBody.error && responseBody.error.message && + responseBody.error.message.toLowerCase().includes('exceed your account\'s rate limit')) { + isRateLimited = true; + } + } catch (e) { + // 如果解析失败,检查原始字符串 + if (response.body && response.body.toLowerCase().includes('exceed your account\'s rate limit')) { + isRateLimited = true; + } + } + + if (isRateLimited) { + logger.warn(`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`); + // 标记账号为限流状态并删除粘性会话映射 + await claudeAccountService.markAccountRateLimited(accountId, sessionHash); + } + } else if (response.statusCode === 200 || response.statusCode === 201) { + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId); + if (isRateLimited) { + await claudeAccountService.removeAccountRateLimit(accountId); + } + } + // 记录成功的API调用 const inputTokens = requestBody.messages ? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 : 0; // 粗略估算 @@ -408,7 +437,7 @@ class ClaudeRelayService { const proxyAgent = await this._getProxyAgent(accountId); // 发送流式请求并捕获usage数据 - return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback); + return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash); } catch (error) { logger.error('❌ Claude stream relay with usage capture failed:', error); throw error; @@ -416,7 +445,7 @@ class ClaudeRelayService { } // 🌊 发送流式请求到Claude API(带usage数据捕获) - async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback) { + async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash) { return new Promise((resolve, reject) => { const url = new URL(this.claudeApiUrl); @@ -457,6 +486,7 @@ class ClaudeRelayService { let buffer = ''; let finalUsageReported = false; // 防止重复统计的标志 let collectedUsageData = {}; // 收集来自不同事件的usage数据 + let rateLimitDetected = false; // 限流检测标志 // 监听数据块,解析SSE并寻找usage信息 res.on('data', (chunk) => { @@ -517,6 +547,13 @@ class ClaudeRelayService { } } + // 检查是否有限流错误 + if (data.type === 'error' && data.error && data.error.message && + data.error.message.toLowerCase().includes('exceed your account\'s rate limit')) { + rateLimitDetected = true; + logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`); + } + } catch (parseError) { // 忽略JSON解析错误,继续处理 logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100)); @@ -525,7 +562,7 @@ class ClaudeRelayService { } }); - res.on('end', () => { + res.on('end', async () => { // 处理缓冲区中剩余的数据 if (buffer.trim()) { responseStream.write(buffer); @@ -537,6 +574,18 @@ class ClaudeRelayService { logger.warn('⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.'); } + // 处理限流状态 + if (rateLimitDetected || res.statusCode === 429) { + // 标记账号为限流状态并删除粘性会话映射 + await claudeAccountService.markAccountRateLimited(accountId, sessionHash); + } else if (res.statusCode === 200) { + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId); + if (isRateLimited) { + await claudeAccountService.removeAccountRateLimit(accountId); + } + } + logger.debug('🌊 Claude stream response with usage capture completed'); resolve(); }); diff --git a/web/admin/app.js b/web/admin/app.js index 6f7f10fe..999f84e0 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -77,6 +77,15 @@ const app = createApp({ usageTrendChart: null, trendPeriod: 7, trendData: [], + trendGranularity: 'day', // 新增:趋势图粒度(day/hour) + + // API Keys 使用趋势 + apiKeysUsageTrendChart: null, + apiKeysTrendData: { + data: [], + topApiKeys: [], + totalApiKeys: 0 + }, // 统一的日期筛选 dateFilter: { @@ -91,6 +100,10 @@ const app = createApp({ { value: '30days', label: '近30天', days: 30 } ] }, + defaultTime: [ + new Date(2000, 1, 1, 0, 0, 0), + new Date(2000, 2, 1, 23, 59, 59), + ], showDateRangePicker: false, // 日期范围选择器显示状态 dateRangeInputValue: '', // 日期范围显示文本 @@ -247,8 +260,11 @@ const app = createApp({ // 初始化日期筛选器和图表数据 this.initializeDateFilter(); - // 预加载账号列表,以便在API Keys页面能正确显示绑定账号名称 - this.loadAccounts().then(() => { + // 预加载账号列表和API Keys,以便正确显示绑定关系 + Promise.all([ + this.loadAccounts(), + this.loadApiKeys() + ]).then(() => { // 根据当前活跃标签页加载数据 this.loadCurrentTabData(); }); @@ -257,6 +273,7 @@ const app = createApp({ this.waitForChartJS().then(() => { this.loadDashboardModelStats(); this.loadUsageTrend(); + this.loadApiKeysUsageTrend(); }); } } else { @@ -422,6 +439,10 @@ const app = createApp({ // 验证账户类型切换 if (this.editAccountForm.accountType === 'shared' && this.editAccountForm.originalAccountType === 'dedicated') { + // 确保API Keys数据已加载,以便正确计算绑定数量 + if (this.apiKeys.length === 0) { + await this.loadApiKeys(); + } const boundKeysCount = this.getBoundApiKeysCount(this.editAccountForm.id); if (boundKeysCount > 0) { this.showToast(`无法切换到共享账户,该账户绑定了 ${boundKeysCount} 个API Key,请先解绑所有API Key`, 'error', '切换失败'); @@ -756,6 +777,7 @@ const app = createApp({ this.waitForChartJS().then(() => { this.loadDashboardModelStats(); this.loadUsageTrend(); + this.loadApiKeysUsageTrend(); }); break; case 'apiKeys': @@ -766,7 +788,11 @@ const app = createApp({ ]); break; case 'accounts': - this.loadAccounts(); + // 加载账户时同时加载API Keys,以便正确计算绑定数量 + Promise.all([ + this.loadAccounts(), + this.loadApiKeys() + ]); break; case 'models': this.loadModelStats(); @@ -819,6 +845,19 @@ const app = createApp({ } this.usageTrendChart = null; } + + // 清理API Keys使用趋势图表 + if (this.apiKeysUsageTrendChart) { + try { + // 先停止所有动画 + this.apiKeysUsageTrendChart.stop(); + // 再销毁图表 + this.apiKeysUsageTrendChart.destroy(); + } catch (error) { + console.warn('Error destroying API keys usage trend chart:', error); + } + this.apiKeysUsageTrendChart = null; + } }, // 检查DOM元素是否存在且有效 @@ -1017,6 +1056,7 @@ const app = createApp({ activeApiKeys: overview.activeApiKeys || 0, totalAccounts: overview.totalClaudeAccounts || 0, activeAccounts: overview.activeClaudeAccounts || 0, + rateLimitedAccounts: overview.rateLimitedClaudeAccounts || 0, todayRequests: recentActivity.requestsToday || 0, totalRequests: overview.totalRequestsUsed || 0, todayTokens: recentActivity.tokensToday || 0, @@ -1263,6 +1303,11 @@ const app = createApp({ }, async deleteAccount(accountId) { + // 确保API Keys数据已加载,以便正确计算绑定数量 + if (this.apiKeys.length === 0) { + await this.loadApiKeys(); + } + // 检查是否有API Key绑定到此账号 const boundKeysCount = this.getBoundApiKeysCount(accountId); if (boundKeysCount > 0) { @@ -1529,11 +1574,68 @@ const app = createApp({ await this.loadUsageTrend(); }, + // 加载API Keys使用趋势数据 + async loadApiKeysUsageTrend() { + console.log('Loading API keys usage trend data, granularity:', this.trendGranularity); + try { + let url = '/admin/api-keys-usage-trend?'; + + if (this.trendGranularity === 'hour') { + // 小时粒度,传递开始和结束时间 + url += `granularity=hour`; + if (this.dateFilter.customRange && this.dateFilter.customRange.length === 2) { + url += `&startDate=${encodeURIComponent(this.dateFilter.customRange[0])}`; + url += `&endDate=${encodeURIComponent(this.dateFilter.customRange[1])}`; + } + } else { + // 天粒度,传递天数 + url += `granularity=day&days=${this.trendPeriod}`; + } + + const response = await fetch(url, { + headers: { 'Authorization': 'Bearer ' + this.authToken } + }); + + if (!response.ok) { + console.error('API keys usage trend API error:', response.status, response.statusText); + return; + } + + const data = await response.json(); + + if (data.success) { + this.apiKeysTrendData = { + data: data.data || [], + topApiKeys: data.topApiKeys || [], + totalApiKeys: data.totalApiKeys || 0 + }; + console.log('Loaded API keys trend data:', this.apiKeysTrendData); + this.updateApiKeysUsageTrendChart(); + } + } catch (error) { + console.error('Failed to load API keys usage trend:', error); + } + }, + // 加载使用趋势数据 async loadUsageTrend() { - console.log('Loading usage trend data, period:', this.trendPeriod, 'authToken:', !!this.authToken); + console.log('Loading usage trend data, period:', this.trendPeriod, 'granularity:', this.trendGranularity, 'authToken:', !!this.authToken); try { - const response = await fetch('/admin/usage-trend?days=' + this.trendPeriod, { + let url = '/admin/usage-trend?'; + + if (this.trendGranularity === 'hour') { + // 小时粒度,传递开始和结束时间 + url += `granularity=hour`; + if (this.dateFilter.customRange && this.dateFilter.customRange.length === 2) { + url += `&startDate=${encodeURIComponent(this.dateFilter.customRange[0])}`; + url += `&endDate=${encodeURIComponent(this.dateFilter.customRange[1])}`; + } + } else { + // 天粒度,传递天数 + url += `granularity=day&days=${this.trendPeriod}`; + } + + const response = await fetch(url, { headers: { 'Authorization': 'Bearer ' + this.authToken } }); @@ -1601,7 +1703,23 @@ const app = createApp({ return; } - const labels = this.trendData.map(item => item.date); + // 根据粒度格式化标签 + const labels = this.trendData.map(item => { + if (this.trendGranularity === 'hour') { + // 小时粒度:从hour字段提取时间 + if (item.hour) { + const date = new Date(item.hour); + return `${String(date.getHours()).padStart(2, '0')}:00`; + } + // 后备方案:从date字段解析 + const [, time] = item.date.split(':'); + return `${time}:00`; + } else { + // 天粒度:显示日期 + return item.date; + } + }); + const inputData = this.trendData.map(item => item.inputTokens || 0); const outputData = this.trendData.map(item => item.outputTokens || 0); const cacheCreateData = this.trendData.map(item => item.cacheCreateTokens || 0); @@ -1676,6 +1794,19 @@ const app = createApp({ intersect: false, }, scales: { + x: { + type: 'category', + display: true, + title: { + display: true, + text: this.trendGranularity === 'hour' ? '时间' : '日期' + }, + ticks: { + autoSkip: true, + maxRotation: this.trendGranularity === 'hour' ? 45 : 0, + minRotation: this.trendGranularity === 'hour' ? 45 : 0 + } + }, y: { type: 'linear', display: true, @@ -1711,6 +1842,25 @@ const app = createApp({ mode: 'index', intersect: false, callbacks: { + title: (tooltipItems) => { + if (tooltipItems.length === 0) return ''; + const index = tooltipItems[0].dataIndex; + const item = this.trendData[index]; + + if (this.trendGranularity === 'hour' && item.hour) { + // 小时粒度:显示完整的日期时间 + const date = new Date(item.hour); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + // 天粒度:保持原有标签 + return tooltipItems[0].label; + }, label: function(context) { const label = context.dataset.label || ''; let value = context.parsed.y; @@ -1739,6 +1889,178 @@ const app = createApp({ } }, + // 更新API Keys使用趋势图 + updateApiKeysUsageTrendChart() { + // 检查Chart.js是否已加载 + if (typeof Chart === 'undefined') { + console.warn('Chart.js not loaded yet, retrying...'); + setTimeout(() => this.updateApiKeysUsageTrendChart(), 500); + return; + } + + // 严格检查DOM元素是否有效 + if (!this.isElementValid('apiKeysUsageTrendChart')) { + console.error('API keys usage trend chart canvas element not found or invalid'); + return; + } + + const ctx = document.getElementById('apiKeysUsageTrendChart'); + + // 安全销毁现有图表 + if (this.apiKeysUsageTrendChart) { + try { + this.apiKeysUsageTrendChart.destroy(); + } catch (error) { + console.warn('Error destroying API keys usage trend chart:', error); + } + this.apiKeysUsageTrendChart = null; + } + + // 如果没有数据,不创建图表 + if (!this.apiKeysTrendData.data || this.apiKeysTrendData.data.length === 0) { + console.warn('No API keys trend data available, skipping chart creation'); + return; + } + + // 准备数据 + const labels = this.apiKeysTrendData.data.map(item => { + if (this.trendGranularity === 'hour') { + const date = new Date(item.hour); + return `${String(date.getHours()).padStart(2, '0')}:00`; + } + return item.date; + }); + + // 获取所有API Key的数据集 + const datasets = []; + const colors = [ + 'rgb(102, 126, 234)', + 'rgb(240, 147, 251)', + 'rgb(59, 130, 246)', + 'rgb(147, 51, 234)', + 'rgb(34, 197, 94)', + 'rgb(251, 146, 60)', + 'rgb(239, 68, 68)', + 'rgb(16, 185, 129)', + 'rgb(245, 158, 11)', + 'rgb(236, 72, 153)' + ]; + + // 只显示前10个使用量最多的API Key + this.apiKeysTrendData.topApiKeys.forEach((apiKeyId, index) => { + const data = this.apiKeysTrendData.data.map(item => { + return item.apiKeys[apiKeyId] ? item.apiKeys[apiKeyId].tokens : 0; + }); + + // 获取API Key名称 + const apiKeyName = this.apiKeysTrendData.data.find(item => + item.apiKeys[apiKeyId] + )?.apiKeys[apiKeyId]?.name || `API Key ${apiKeyId}`; + + datasets.push({ + label: apiKeyName, + data: data, + borderColor: colors[index % colors.length], + backgroundColor: colors[index % colors.length] + '20', + tension: 0.3, + fill: false + }); + }); + + try { + // 最后一次检查元素有效性 + if (!this.isElementValid('apiKeysUsageTrendChart')) { + throw new Error('Canvas element is not valid for chart creation'); + } + + this.apiKeysUsageTrendChart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, // 禁用动画防止异步渲染问题 + interaction: { + mode: 'index', + intersect: false, + }, + scales: { + x: { + type: 'category', + display: true, + title: { + display: true, + text: this.trendGranularity === 'hour' ? '时间' : '日期' + }, + ticks: { + autoSkip: true, + maxRotation: this.trendGranularity === 'hour' ? 45 : 0, + minRotation: this.trendGranularity === 'hour' ? 45 : 0 + } + }, + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: 'Token 数量' + }, + ticks: { + callback: function(value) { + return value.toLocaleString(); + } + } + } + }, + plugins: { + legend: { + position: 'top', + labels: { + usePointStyle: true, + padding: 15 + } + }, + tooltip: { + mode: 'index', + intersect: false, + callbacks: { + title: (tooltipItems) => { + if (tooltipItems.length === 0) return ''; + const index = tooltipItems[0].dataIndex; + const item = this.apiKeysTrendData.data[index]; + + if (this.trendGranularity === 'hour' && item.hour) { + const date = new Date(item.hour); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + return tooltipItems[0].label; + }, + label: function(context) { + const label = context.dataset.label || ''; + const value = context.parsed.y; + return label + ': ' + value.toLocaleString() + ' tokens'; + } + } + } + } + } + }); + } catch (error) { + console.error('Error creating API keys usage trend chart:', error); + this.apiKeysUsageTrendChart = null; + } + }, + // 切换API Key模型统计展开状态 toggleApiKeyModelStats(keyId) { if (!keyId) { @@ -1933,20 +2255,51 @@ const app = createApp({ // 根据预设计算并设置自定义时间框的值 const option = this.dateFilter.presetOptions.find(opt => opt.value === preset); if (option) { - const today = new Date(); - const startDate = new Date(today); - startDate.setDate(today.getDate() - (option.days - 1)); + const now = new Date(); + let startDate, endDate; + + if (this.trendGranularity === 'hour') { + // 小时粒度的预设处理 + if (preset === 'last24h') { + endDate = new Date(now); + startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); + } else if (preset === 'yesterday') { + // 昨天的00:00到23:59 + startDate = new Date(now); + startDate.setDate(startDate.getDate() - 1); + startDate.setHours(0, 0, 0, 0); + endDate = new Date(startDate); + endDate.setHours(23, 59, 59, 999); + } else if (preset === 'dayBefore') { + // 前天的00:00到23:59 + startDate = new Date(now); + startDate.setDate(startDate.getDate() - 2); + startDate.setHours(0, 0, 0, 0); + endDate = new Date(startDate); + endDate.setHours(23, 59, 59, 999); + } + } else { + // 天粒度的预设处理(保持原有逻辑) + endDate = new Date(now); + startDate = new Date(now); + startDate.setDate(now.getDate() - (option.days - 1)); + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + } // 格式化为 Element Plus 需要的格式 const formatDate = (date) => { return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + - String(date.getDate()).padStart(2, '0') + ' 00:00:00'; + String(date.getDate()).padStart(2, '0') + ' ' + + String(date.getHours()).padStart(2, '0') + ':' + + String(date.getMinutes()).padStart(2, '0') + ':' + + String(date.getSeconds()).padStart(2, '0'); }; this.dateFilter.customRange = [ formatDate(startDate), - formatDate(today) + formatDate(endDate) ]; } @@ -2105,6 +2458,61 @@ const app = createApp({ // 重新加载数据 this.loadDashboardModelStats(); this.loadUsageTrend(); + this.loadApiKeysUsageTrend(); + }, + + // 设置趋势图粒度 + setTrendGranularity(granularity) { + console.log('Setting trend granularity to:', granularity); + this.trendGranularity = granularity; + + // 根据粒度更新预设选项 + if (granularity === 'hour') { + this.dateFilter.presetOptions = [ + { value: 'last24h', label: '近24小时', hours: 24 }, + { value: 'yesterday', label: '昨天', hours: 24 }, + { value: 'dayBefore', label: '前天', hours: 24 } + ]; + + // 检查当前自定义日期范围是否超过24小时 + if (this.dateFilter.type === 'custom' && this.dateFilter.customRange && this.dateFilter.customRange.length === 2) { + const start = new Date(this.dateFilter.customRange[0]); + const end = new Date(this.dateFilter.customRange[1]); + const hoursDiff = (end - start) / (1000 * 60 * 60); + + if (hoursDiff > 24) { + this.showToast('切换到小时粒度,日期范围已调整为近24小时', 'info'); + this.dateFilter.preset = 'last24h'; + this.setDateFilterPreset('last24h'); + } + } else if (['today', '7days', '30days'].includes(this.dateFilter.preset)) { + // 预设不兼容,切换到近24小时 + this.dateFilter.preset = 'last24h'; + this.setDateFilterPreset('last24h'); + } + } else { + // 恢复天粒度的选项 + this.dateFilter.presetOptions = [ + { value: 'today', label: '今天', days: 1 }, + { value: '7days', label: '近7天', days: 7 }, + { value: '30days', label: '近30天', days: 30 } + ]; + + // 如果当前是小时粒度的预设,切换到天粒度的默认预设 + if (['last24h', 'yesterday', 'dayBefore'].includes(this.dateFilter.preset)) { + this.dateFilter.preset = '7days'; + this.setDateFilterPreset('7days'); + } else if (this.dateFilter.type === 'custom') { + // 自定义日期范围在天粒度下通常不需要调整,因为24小时肯定在31天内 + // 只需要重新加载数据 + this.refreshChartsData(); + return; + } + } + + // 重新加载数据 + this.loadUsageTrend(); + this.loadApiKeysUsageTrend(); }, // API Keys 日期筛选方法 @@ -2293,22 +2701,47 @@ const app = createApp({ // 检查日期范围限制 const start = new Date(value[0]); const end = new Date(value[1]); - const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; - if (daysDiff > 31) { - this.showToast('日期范围不能超过31天', 'warning', '范围限制'); - // 重置为默认7天 - this.dateFilter.customRange = null; - this.dateFilter.type = 'preset'; - this.dateFilter.preset = '7days'; - return; + if (this.trendGranularity === 'hour') { + // 小时粒度:限制24小时 + const hoursDiff = (end - start) / (1000 * 60 * 60); + if (hoursDiff > 24) { + this.showToast('小时粒度下日期范围不能超过24小时', 'warning', '范围限制'); + // 调整结束时间为开始时间后24小时 + const newEnd = new Date(start.getTime() + 24 * 60 * 60 * 1000); + const formatDate = (date) => { + return date.getFullYear() + '-' + + String(date.getMonth() + 1).padStart(2, '0') + '-' + + String(date.getDate()).padStart(2, '0') + ' ' + + String(date.getHours()).padStart(2, '0') + ':' + + String(date.getMinutes()).padStart(2, '0') + ':' + + String(date.getSeconds()).padStart(2, '0'); + }; + this.dateFilter.customRange = [ + formatDate(start), + formatDate(newEnd) + ]; + this.dateFilter.customEnd = newEnd.toISOString().split('T')[0]; + return; + } + } else { + // 天粒度:限制31天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; + if (daysDiff > 31) { + this.showToast('日期范围不能超过31天', 'warning', '范围限制'); + // 重置为默认7天 + this.dateFilter.customRange = null; + this.dateFilter.type = 'preset'; + this.dateFilter.preset = '7days'; + return; + } } this.refreshChartsData(); } else if (value === null) { // 清空时恢复默认 this.dateFilter.type = 'preset'; - this.dateFilter.preset = '7days'; + this.dateFilter.preset = this.trendGranularity === 'hour' ? 'last24h' : '7days'; this.dateFilter.customStart = ''; this.dateFilter.customEnd = ''; this.refreshChartsData(); diff --git a/web/admin/index.html b/web/admin/index.html index bd0fa72e..6bdc9716 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -160,7 +160,12 @@

Claude账户

{{ dashboardData.totalAccounts }}

-

活跃: {{ dashboardData.activeAccounts || 0 }}

+

+ 活跃: {{ dashboardData.activeAccounts || 0 }} + + | 限流: {{ dashboardData.rateLimitedAccounts }} + +

@@ -292,21 +297,53 @@
+ +
+ + +
+ - +
+ + + 最多24小时 + +
-
+