From a6ab6b7abe2338657300889ecb08f5256cd43a82 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 30 Jul 2025 14:27:34 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E6=BB=91=E5=8A=A8=E7=AA=97=E5=8F=A3=E7=9A=84=E5=AE=9E?= =?UTF-8?q?=E6=97=B6RPM/TPM=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加系统级分钟统计,支持1-60分钟可配置时间窗口 - 新增 getRealtimeSystemMetrics 方法计算滑动窗口内的平均值 - 前端显示实时RPM/TPM,标注时间窗口和数据来源 - 修复 EditApiKeyModal 中模型限制和客户端限制复选框状态错误 - 优化性能:使用Pipeline批量操作替代Promise.all - TPM包含所有token类型:input、output、cache_creation、cache_read - 添加降级方案:实时数据不可用时返回历史平均值 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 1 + src/models/redis.js | 280 ++++++++++++------ src/routes/admin.js | 13 +- src/services/claudeConsoleRelayService.js | 4 +- .../components/apikeys/EditApiKeyModal.vue | 5 +- web/admin-spa/src/stores/dashboard.js | 9 + web/admin-spa/src/views/DashboardView.vue | 28 +- 7 files changed, 237 insertions(+), 103 deletions(-) diff --git a/.env.example b/.env.example index 33a34d06..a796b496 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ TOKEN_USAGE_RETENTION=2592000000 HEALTH_CHECK_INTERVAL=60000 SYSTEM_TIMEZONE=Asia/Shanghai TIMEZONE_OFFSET=8 +METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟 # 🎨 Web 界面配置 WEB_TITLE=Claude Relay Service diff --git a/src/models/redis.js b/src/models/redis.js index f8f10723..57e4b013 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -186,6 +186,10 @@ class RedisClient { const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`; const keyModelHourly = `usage:${keyId}:model:hourly:${model}:${currentHour}`; // 新增API Key模型小时级别 + // 新增:系统级分钟统计 + const minuteTimestamp = Math.floor(now.getTime() / 60000); + const systemMinuteKey = `system:metrics:minute:${minuteTimestamp}`; + // 智能处理输入输出token分配 const finalInputTokens = inputTokens || 0; const finalOutputTokens = outputTokens || (finalInputTokens > 0 ? 0 : tokens); @@ -197,96 +201,122 @@ class RedisClient { // 核心token(不包括缓存)- 用于与历史数据兼容 const coreTokens = finalInputTokens + finalOutputTokens; - await Promise.all([ - // 核心token统计(保持向后兼容) - this.client.hincrby(key, 'totalTokens', coreTokens), - this.client.hincrby(key, 'totalInputTokens', finalInputTokens), - this.client.hincrby(key, 'totalOutputTokens', finalOutputTokens), - // 缓存token统计(新增) - this.client.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens), - this.client.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens), - this.client.hincrby(key, 'totalAllTokens', totalTokens), // 包含所有类型的总token - // 请求计数 - this.client.hincrby(key, 'totalRequests', 1), - // 每日统计 - this.client.hincrby(daily, 'tokens', coreTokens), - this.client.hincrby(daily, 'inputTokens', finalInputTokens), - this.client.hincrby(daily, 'outputTokens', finalOutputTokens), - this.client.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens), - this.client.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens), - this.client.hincrby(daily, 'allTokens', totalTokens), - this.client.hincrby(daily, 'requests', 1), - // 每月统计 - this.client.hincrby(monthly, 'tokens', coreTokens), - this.client.hincrby(monthly, 'inputTokens', finalInputTokens), - this.client.hincrby(monthly, 'outputTokens', finalOutputTokens), - this.client.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens), - this.client.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens), - this.client.hincrby(monthly, 'allTokens', totalTokens), - this.client.hincrby(monthly, 'requests', 1), - // 按模型统计 - 每日 - this.client.hincrby(modelDaily, 'inputTokens', finalInputTokens), - this.client.hincrby(modelDaily, 'outputTokens', finalOutputTokens), - this.client.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens), - this.client.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens), - this.client.hincrby(modelDaily, 'allTokens', totalTokens), - this.client.hincrby(modelDaily, 'requests', 1), - // 按模型统计 - 每月 - this.client.hincrby(modelMonthly, 'inputTokens', finalInputTokens), - this.client.hincrby(modelMonthly, 'outputTokens', finalOutputTokens), - this.client.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens), - this.client.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens), - this.client.hincrby(modelMonthly, 'allTokens', totalTokens), - this.client.hincrby(modelMonthly, 'requests', 1), - // API Key级别的模型统计 - 每日 - this.client.hincrby(keyModelDaily, 'inputTokens', finalInputTokens), - this.client.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens), - this.client.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens), - this.client.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens), - this.client.hincrby(keyModelDaily, 'allTokens', totalTokens), - this.client.hincrby(keyModelDaily, 'requests', 1), - // API Key级别的模型统计 - 每月 - this.client.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens), - this.client.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens), - this.client.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens), - 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(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期 - ]); + // 使用Pipeline优化性能 + const pipeline = this.client.pipeline(); + + // 现有的统计保持不变 + // 核心token统计(保持向后兼容) + pipeline.hincrby(key, 'totalTokens', coreTokens); + pipeline.hincrby(key, 'totalInputTokens', finalInputTokens); + pipeline.hincrby(key, 'totalOutputTokens', finalOutputTokens); + // 缓存token统计(新增) + pipeline.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(key, 'totalAllTokens', totalTokens); // 包含所有类型的总token + // 请求计数 + pipeline.hincrby(key, 'totalRequests', 1); + + // 每日统计 + pipeline.hincrby(daily, 'tokens', coreTokens); + pipeline.hincrby(daily, 'inputTokens', finalInputTokens); + pipeline.hincrby(daily, 'outputTokens', finalOutputTokens); + pipeline.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(daily, 'allTokens', totalTokens); + pipeline.hincrby(daily, 'requests', 1); + + // 每月统计 + pipeline.hincrby(monthly, 'tokens', coreTokens); + pipeline.hincrby(monthly, 'inputTokens', finalInputTokens); + pipeline.hincrby(monthly, 'outputTokens', finalOutputTokens); + pipeline.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(monthly, 'allTokens', totalTokens); + pipeline.hincrby(monthly, 'requests', 1); + + // 按模型统计 - 每日 + pipeline.hincrby(modelDaily, 'inputTokens', finalInputTokens); + pipeline.hincrby(modelDaily, 'outputTokens', finalOutputTokens); + pipeline.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(modelDaily, 'allTokens', totalTokens); + pipeline.hincrby(modelDaily, 'requests', 1); + + // 按模型统计 - 每月 + pipeline.hincrby(modelMonthly, 'inputTokens', finalInputTokens); + pipeline.hincrby(modelMonthly, 'outputTokens', finalOutputTokens); + pipeline.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(modelMonthly, 'allTokens', totalTokens); + pipeline.hincrby(modelMonthly, 'requests', 1); + + // API Key级别的模型统计 - 每日 + pipeline.hincrby(keyModelDaily, 'inputTokens', finalInputTokens); + pipeline.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens); + pipeline.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(keyModelDaily, 'allTokens', totalTokens); + pipeline.hincrby(keyModelDaily, 'requests', 1); + + // API Key级别的模型统计 - 每月 + pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens); + pipeline.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens); + pipeline.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(keyModelMonthly, 'allTokens', totalTokens); + pipeline.hincrby(keyModelMonthly, 'requests', 1); + + // 小时级别统计 + pipeline.hincrby(hourly, 'tokens', coreTokens); + pipeline.hincrby(hourly, 'inputTokens', finalInputTokens); + pipeline.hincrby(hourly, 'outputTokens', finalOutputTokens); + pipeline.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(hourly, 'allTokens', totalTokens); + pipeline.hincrby(hourly, 'requests', 1); + + // 按模型统计 - 每小时 + pipeline.hincrby(modelHourly, 'inputTokens', finalInputTokens); + pipeline.hincrby(modelHourly, 'outputTokens', finalOutputTokens); + pipeline.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(modelHourly, 'allTokens', totalTokens); + pipeline.hincrby(modelHourly, 'requests', 1); + + // API Key级别的模型统计 - 每小时 + pipeline.hincrby(keyModelHourly, 'inputTokens', finalInputTokens); + pipeline.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens); + pipeline.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens); + pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens); + pipeline.hincrby(keyModelHourly, 'requests', 1); + + // 新增:系统级分钟统计 + pipeline.hincrby(systemMinuteKey, 'requests', 1); + pipeline.hincrby(systemMinuteKey, 'totalTokens', totalTokens); + pipeline.hincrby(systemMinuteKey, 'inputTokens', finalInputTokens); + pipeline.hincrby(systemMinuteKey, 'outputTokens', finalOutputTokens); + pipeline.hincrby(systemMinuteKey, 'cacheCreateTokens', finalCacheCreateTokens); + pipeline.hincrby(systemMinuteKey, 'cacheReadTokens', finalCacheReadTokens); + + // 设置过期时间 + pipeline.expire(daily, 86400 * 32); // 32天过期 + pipeline.expire(monthly, 86400 * 365); // 1年过期 + pipeline.expire(hourly, 86400 * 7); // 小时统计7天过期 + pipeline.expire(modelDaily, 86400 * 32); // 模型每日统计32天过期 + pipeline.expire(modelMonthly, 86400 * 365); // 模型每月统计1年过期 + pipeline.expire(modelHourly, 86400 * 7); // 模型小时统计7天过期 + pipeline.expire(keyModelDaily, 86400 * 32); // API Key模型每日统计32天过期 + pipeline.expire(keyModelMonthly, 86400 * 365); // API Key模型每月统计1年过期 + pipeline.expire(keyModelHourly, 86400 * 7); // API Key模型小时统计7天过期 + + // 系统级分钟统计的过期时间(窗口时间的2倍) + const config = require('../../config/config'); + const metricsWindow = config.system.metricsWindow; + pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2); + + // 执行Pipeline + await pipeline.exec(); } // 📊 记录账户级别的使用统计 @@ -974,6 +1004,76 @@ class RedisClient { } } + // 📊 获取实时系统指标(基于滑动窗口) + async getRealtimeSystemMetrics() { + try { + const config = require('../../config/config'); + const windowMinutes = config.system.metricsWindow; + + const now = new Date(); + const currentMinute = Math.floor(now.getTime() / 60000); + + // 使用Pipeline批量获取窗口内的所有分钟数据 + const pipeline = this.client.pipeline(); + for (let i = 0; i < windowMinutes; i++) { + const minuteKey = `system:metrics:minute:${currentMinute - i}`; + pipeline.hgetall(minuteKey); + } + + const results = await pipeline.exec(); + + // 聚合计算 + let totalRequests = 0; + let totalTokens = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheCreateTokens = 0; + let totalCacheReadTokens = 0; + + results.forEach(([err, data]) => { + if (!err && data) { + totalRequests += parseInt(data.requests || 0); + totalTokens += parseInt(data.totalTokens || 0); + totalInputTokens += parseInt(data.inputTokens || 0); + totalOutputTokens += parseInt(data.outputTokens || 0); + totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0); + totalCacheReadTokens += parseInt(data.cacheReadTokens || 0); + } + }); + + // 计算平均值(每分钟) + const realtimeRPM = windowMinutes > 0 ? Math.round((totalRequests / windowMinutes) * 100) / 100 : 0; + const realtimeTPM = windowMinutes > 0 ? Math.round((totalTokens / windowMinutes) * 100) / 100 : 0; + + return { + realtimeRPM, + realtimeTPM, + windowMinutes, + totalRequests, + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheCreateTokens, + totalCacheReadTokens + }; + } catch (error) { + console.error('Error getting realtime system metrics:', error); + // 如果出错,返回历史平均值作为降级方案 + const historicalMetrics = await this.getSystemAverages(); + return { + realtimeRPM: historicalMetrics.systemRPM, + realtimeTPM: historicalMetrics.systemTPM, + windowMinutes: 0, // 标识使用了历史数据 + totalRequests: 0, + totalTokens: historicalMetrics.totalTokens, + totalInputTokens: historicalMetrics.totalInputTokens, + totalOutputTokens: historicalMetrics.totalOutputTokens, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0 + }; + } + } + // 🔗 会话sticky映射管理 async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) { const key = `sticky_session:${sessionHash}`; diff --git a/src/routes/admin.js b/src/routes/admin.js index adb7ef8a..de0a4be4 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1261,13 +1261,14 @@ router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, re // 获取系统概览 router.get('/dashboard', authenticateAdmin, async (req, res) => { try { - const [, apiKeys, claudeAccounts, geminiAccounts, todayStats, systemAverages] = await Promise.all([ + const [, apiKeys, claudeAccounts, geminiAccounts, todayStats, systemAverages, realtimeMetrics] = await Promise.all([ redis.getSystemStats(), apiKeyService.getAllApiKeys(), claudeAccountService.getAllAccounts(), geminiAccountService.getAllAccounts(), redis.getTodayStats(), - redis.getSystemAverages() + redis.getSystemAverages(), + redis.getRealtimeSystemMetrics() ]); // 计算使用统计(统一使用allTokens) @@ -1316,6 +1317,12 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => { rpm: systemAverages.systemRPM, tpm: systemAverages.systemTPM }, + realtimeMetrics: { + rpm: realtimeMetrics.realtimeRPM, + tpm: realtimeMetrics.realtimeTPM, + windowMinutes: realtimeMetrics.windowMinutes, + isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据 + }, systemHealth: { redisConnected: redis.isConnected, claudeAccountsHealthy: activeClaudeAccounts > 0, @@ -1483,7 +1490,7 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => { endTime = new Date(endDate); // 调试日志 - logger.info(`📊 Usage trend hour granularity - received times:`); + logger.info('📊 Usage trend hour granularity - received times:'); logger.info(` startDate (raw): ${startDate}`); logger.info(` endDate (raw): ${endDate}`); logger.info(` startTime (parsed): ${startTime.toISOString()}`); diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js index 71455151..43b239c1 100644 --- a/src/services/claudeConsoleRelayService.js +++ b/src/services/claudeConsoleRelayService.js @@ -88,11 +88,11 @@ class ClaudeConsoleRelayService { logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`); requestConfig.headers['anthropic-beta'] = options.betaHeader; } else { - logger.debug(`[DEBUG] No beta header to add`); + logger.debug('[DEBUG] No beta header to add'); } // 发送请求 - logger.debug(`📤 Sending request to Claude Console API with headers:`, JSON.stringify(requestConfig.headers, null, 2)); + logger.debug('📤 Sending request to Claude Console API with headers:', JSON.stringify(requestConfig.headers, null, 2)); const response = await axios(requestConfig); // 移除监听器(请求成功完成) diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 9eaf5ad2..de0b77b4 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -522,8 +522,9 @@ onMounted(async () => { form.restrictedModels = props.apiKey.restrictedModels || [] form.allowedClients = props.apiKey.allowedClients || [] form.tags = props.apiKey.tags || [] - form.enableModelRestriction = form.restrictedModels.length > 0 - form.enableClientRestriction = form.allowedClients.length > 0 + // 从后端数据中获取实际的启用状态,而不是根据数组长度推断 + form.enableModelRestriction = props.apiKey.enableModelRestriction || false + form.enableClientRestriction = props.apiKey.enableClientRestriction || false }) diff --git a/web/admin-spa/src/stores/dashboard.js b/web/admin-spa/src/stores/dashboard.js index 19a83bda..9f91c907 100644 --- a/web/admin-spa/src/stores/dashboard.js +++ b/web/admin-spa/src/stores/dashboard.js @@ -26,6 +26,10 @@ export const useDashboardStore = defineStore('dashboard', () => { todayCacheReadTokens: 0, systemRPM: 0, systemTPM: 0, + realtimeRPM: 0, + realtimeTPM: 0, + metricsWindow: 5, + isHistoricalMetrics: false, systemStatus: '正常', uptime: 0, systemTimezone: 8 // 默认 UTC+8 @@ -129,6 +133,7 @@ export const useDashboardStore = defineStore('dashboard', () => { const overview = dashboardResponse.data.overview || {} const recentActivity = dashboardResponse.data.recentActivity || {} const systemAverages = dashboardResponse.data.systemAverages || {} + const realtimeMetrics = dashboardResponse.data.realtimeMetrics || {} const systemHealth = dashboardResponse.data.systemHealth || {} dashboardData.value = { @@ -151,6 +156,10 @@ export const useDashboardStore = defineStore('dashboard', () => { todayCacheReadTokens: recentActivity.cacheReadTokensToday || 0, systemRPM: systemAverages.rpm || 0, systemTPM: systemAverages.tpm || 0, + realtimeRPM: realtimeMetrics.rpm || 0, + realtimeTPM: realtimeMetrics.tpm || 0, + metricsWindow: realtimeMetrics.windowMinutes || 5, + isHistoricalMetrics: realtimeMetrics.isHistorical || false, systemStatus: systemHealth.redisConnected ? '正常' : '异常', uptime: systemHealth.uptime || 0, systemTimezone: dashboardResponse.data.systemTimezone || 8 diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue index 68b4e2a3..c728f5da 100644 --- a/web/admin-spa/src/views/DashboardView.vue +++ b/web/admin-spa/src/views/DashboardView.vue @@ -111,9 +111,17 @@
-

平均RPM

-

{{ dashboardData.systemRPM || 0 }}

-

每分钟请求数

+

+ 实时RPM + ({{ dashboardData.metricsWindow }}分钟) +

+

{{ dashboardData.realtimeRPM || 0 }}

+

+ 每分钟请求数 + + 历史数据 + +

@@ -124,9 +132,17 @@
-

平均TPM

-

{{ dashboardData.systemTPM || 0 }}

-

每分钟Token数

+

+ 实时TPM + ({{ dashboardData.metricsWindow }}分钟) +

+

{{ dashboardData.realtimeTPM || 0 }}

+

+ 每分钟Token数 + + 历史数据 + +

From 21461863afc38069c713ea40f0af399e818c9067 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 30 Jul 2025 14:49:39 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=9E?= =?UTF-8?q?=E6=97=B6RPM/TPM=E6=8C=87=E6=A0=87=E6=98=BE=E7=A4=BA=E4=B8=BA0?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加调试日志以追踪数据读取过程 - 修复getRealtimeSystemMetrics中的数据验证逻辑 - 添加测试脚本用于验证时间戳匹配问题 - 改进错误处理和日志记录 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- scripts/test-realtime-metrics.js | 69 +++++++++ src/models/redis.js | 28 +++- web/admin-spa/src/views/DashboardView.vue | 179 +++++++++++++++++++++- 3 files changed, 265 insertions(+), 11 deletions(-) create mode 100644 scripts/test-realtime-metrics.js diff --git a/scripts/test-realtime-metrics.js b/scripts/test-realtime-metrics.js new file mode 100644 index 00000000..ba236822 --- /dev/null +++ b/scripts/test-realtime-metrics.js @@ -0,0 +1,69 @@ +const redis = require('../src/models/redis'); +const logger = require('../src/utils/logger'); + +async function testRealtimeMetrics() { + try { + // 连接Redis + await redis.connect(); + + // 获取当前时间戳 + const now = new Date(); + const currentMinute = Math.floor(now.getTime() / 60000); + + console.log('=== 时间戳测试 ==='); + console.log('当前时间:', now.toISOString()); + console.log('当前分钟时间戳:', currentMinute); + console.log(''); + + // 检查最近5分钟的键 + console.log('=== 检查Redis键 ==='); + const client = redis.getClient(); + for (let i = 0; i < 5; i++) { + const minuteKey = `system:metrics:minute:${currentMinute - i}`; + const exists = await client.exists(minuteKey); + const data = await client.hgetall(minuteKey); + + console.log(`键: ${minuteKey}`); + console.log(` 存在: ${exists ? '是' : '否'}`); + if (exists && data) { + console.log(` 数据: requests=${data.requests}, totalTokens=${data.totalTokens}`); + } + console.log(''); + } + + // 调用getRealtimeSystemMetrics + console.log('=== 调用 getRealtimeSystemMetrics ==='); + const metrics = await redis.getRealtimeSystemMetrics(); + console.log('结果:', JSON.stringify(metrics, null, 2)); + + // 列出所有system:metrics:minute:*键 + console.log('\n=== 所有系统指标键 ==='); + const allKeys = await client.keys('system:metrics:minute:*'); + console.log('找到的键数量:', allKeys.length); + if (allKeys.length > 0) { + // 排序并显示最新的10个 + allKeys.sort((a, b) => { + const aNum = parseInt(a.split(':').pop()); + const bNum = parseInt(b.split(':').pop()); + return bNum - aNum; + }); + + console.log('最新的10个键:'); + for (let i = 0; i < Math.min(10, allKeys.length); i++) { + const key = allKeys[i]; + const timestamp = parseInt(key.split(':').pop()); + const timeDiff = currentMinute - timestamp; + console.log(` ${key} (${timeDiff}分钟前)`); + } + } + + } catch (error) { + console.error('测试失败:', error); + } finally { + await redis.disconnect(); + process.exit(0); + } +} + +// 运行测试 +testRealtimeMetrics(); \ No newline at end of file diff --git a/src/models/redis.js b/src/models/redis.js index 57e4b013..659432db 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1008,18 +1008,25 @@ class RedisClient { async getRealtimeSystemMetrics() { try { const config = require('../../config/config'); - const windowMinutes = config.system.metricsWindow; + const windowMinutes = config.system.metricsWindow || 5; const now = new Date(); const currentMinute = Math.floor(now.getTime() / 60000); + // 调试:打印当前时间和分钟时间戳 + logger.debug(`🔍 Realtime metrics - Current time: ${now.toISOString()}, Minute timestamp: ${currentMinute}`); + // 使用Pipeline批量获取窗口内的所有分钟数据 const pipeline = this.client.pipeline(); + const minuteKeys = []; for (let i = 0; i < windowMinutes; i++) { const minuteKey = `system:metrics:minute:${currentMinute - i}`; + minuteKeys.push(minuteKey); pipeline.hgetall(minuteKey); } + logger.debug(`🔍 Realtime metrics - Checking keys: ${minuteKeys.join(', ')}`); + const results = await pipeline.exec(); // 聚合计算 @@ -1029,23 +1036,32 @@ class RedisClient { let totalOutputTokens = 0; let totalCacheCreateTokens = 0; let totalCacheReadTokens = 0; + let validDataCount = 0; - results.forEach(([err, data]) => { - if (!err && data) { + results.forEach(([err, data], index) => { + if (!err && data && Object.keys(data).length > 0) { + validDataCount++; totalRequests += parseInt(data.requests || 0); totalTokens += parseInt(data.totalTokens || 0); totalInputTokens += parseInt(data.inputTokens || 0); totalOutputTokens += parseInt(data.outputTokens || 0); totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0); totalCacheReadTokens += parseInt(data.cacheReadTokens || 0); + + logger.debug(`🔍 Realtime metrics - Key ${minuteKeys[index]} data:`, { + requests: data.requests, + totalTokens: data.totalTokens + }); } }); + logger.debug(`🔍 Realtime metrics - Valid data count: ${validDataCount}/${windowMinutes}, Total requests: ${totalRequests}, Total tokens: ${totalTokens}`); + // 计算平均值(每分钟) const realtimeRPM = windowMinutes > 0 ? Math.round((totalRequests / windowMinutes) * 100) / 100 : 0; const realtimeTPM = windowMinutes > 0 ? Math.round((totalTokens / windowMinutes) * 100) / 100 : 0; - return { + const result = { realtimeRPM, realtimeTPM, windowMinutes, @@ -1056,6 +1072,10 @@ class RedisClient { totalCacheCreateTokens, totalCacheReadTokens }; + + logger.debug(`🔍 Realtime metrics - Final result:`, result); + + return result; } catch (error) { console.error('Error getting realtime system metrics:', error); // 如果出错,返回历史平均值作为降级方案 diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue index c728f5da..7820ed5f 100644 --- a/web/admin-spa/src/views/DashboardView.vue +++ b/web/admin-spa/src/views/DashboardView.vue @@ -1,5 +1,45 @@ \ No newline at end of file From 321be986a6b1fe61bd592973873f300d64368fa2 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 30 Jul 2025 15:07:05 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA=E5=92=8C?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化实时TPM显示格式,添加智能单位(K/M) - 折线图tooltip添加token数单位格式化 - API Keys使用趋势图增加USD金额计算和显示 - 实现鼠标悬浮时数据倒序展示,前3名添加🥇🥈🥉标识 - 后端API添加费用计算支持 - 删除测试脚本,保持代码整洁 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- scripts/test-realtime-metrics.js | 69 ---------------------- src/models/redis.js | 2 +- src/routes/admin.js | 44 ++++++++++---- web/admin-spa/src/views/DashboardView.vue | 71 +++++++++++++++++++++-- 4 files changed, 102 insertions(+), 84 deletions(-) delete mode 100644 scripts/test-realtime-metrics.js diff --git a/scripts/test-realtime-metrics.js b/scripts/test-realtime-metrics.js deleted file mode 100644 index ba236822..00000000 --- a/scripts/test-realtime-metrics.js +++ /dev/null @@ -1,69 +0,0 @@ -const redis = require('../src/models/redis'); -const logger = require('../src/utils/logger'); - -async function testRealtimeMetrics() { - try { - // 连接Redis - await redis.connect(); - - // 获取当前时间戳 - const now = new Date(); - const currentMinute = Math.floor(now.getTime() / 60000); - - console.log('=== 时间戳测试 ==='); - console.log('当前时间:', now.toISOString()); - console.log('当前分钟时间戳:', currentMinute); - console.log(''); - - // 检查最近5分钟的键 - console.log('=== 检查Redis键 ==='); - const client = redis.getClient(); - for (let i = 0; i < 5; i++) { - const minuteKey = `system:metrics:minute:${currentMinute - i}`; - const exists = await client.exists(minuteKey); - const data = await client.hgetall(minuteKey); - - console.log(`键: ${minuteKey}`); - console.log(` 存在: ${exists ? '是' : '否'}`); - if (exists && data) { - console.log(` 数据: requests=${data.requests}, totalTokens=${data.totalTokens}`); - } - console.log(''); - } - - // 调用getRealtimeSystemMetrics - console.log('=== 调用 getRealtimeSystemMetrics ==='); - const metrics = await redis.getRealtimeSystemMetrics(); - console.log('结果:', JSON.stringify(metrics, null, 2)); - - // 列出所有system:metrics:minute:*键 - console.log('\n=== 所有系统指标键 ==='); - const allKeys = await client.keys('system:metrics:minute:*'); - console.log('找到的键数量:', allKeys.length); - if (allKeys.length > 0) { - // 排序并显示最新的10个 - allKeys.sort((a, b) => { - const aNum = parseInt(a.split(':').pop()); - const bNum = parseInt(b.split(':').pop()); - return bNum - aNum; - }); - - console.log('最新的10个键:'); - for (let i = 0; i < Math.min(10, allKeys.length); i++) { - const key = allKeys[i]; - const timestamp = parseInt(key.split(':').pop()); - const timeDiff = currentMinute - timestamp; - console.log(` ${key} (${timeDiff}分钟前)`); - } - } - - } catch (error) { - console.error('测试失败:', error); - } finally { - await redis.disconnect(); - process.exit(0); - } -} - -// 运行测试 -testRealtimeMetrics(); \ No newline at end of file diff --git a/src/models/redis.js b/src/models/redis.js index 659432db..5d934750 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1073,7 +1073,7 @@ class RedisClient { totalCacheReadTokens }; - logger.debug(`🔍 Realtime metrics - Final result:`, result); + logger.debug('🔍 Realtime metrics - Final result:', result); return result; } catch (error) { diff --git a/src/routes/admin.js b/src/routes/admin.js index de0a4be4..213efd54 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1986,15 +1986,27 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { 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); + const inputTokens = parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.outputTokens) || 0; + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + // 计算费用 - 使用默认模型价格,因为小时级别的数据没有模型信息 + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }; + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); hourData.apiKeys[apiKeyId] = { name: apiKeyMap.get(apiKeyId).name, tokens: totalTokens, - requests: parseInt(data.requests) || 0 + requests: parseInt(data.requests) || 0, + cost: costResult.costs.total, + formattedCost: costResult.formatted.total }; } } @@ -2031,15 +2043,27 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { 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); + const inputTokens = parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.outputTokens) || 0; + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + // 计算费用 - 使用默认模型价格,因为日级别的汇总数据没有模型信息 + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }; + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); dayData.apiKeys[apiKeyId] = { name: apiKeyMap.get(apiKeyId).name, tokens: totalTokens, - requests: parseInt(data.requests) || 0 + requests: parseInt(data.requests) || 0, + cost: costResult.costs.total, + formattedCost: costResult.formatted.total }; } } diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue index 7820ed5f..5888b843 100644 --- a/web/admin-spa/src/views/DashboardView.vue +++ b/web/admin-spa/src/views/DashboardView.vue @@ -176,7 +176,7 @@ 实时TPM ({{ dashboardData.metricsWindow }}分钟)

-

{{ dashboardData.realtimeTPM || 0 }}

+

{{ formatNumber(dashboardData.realtimeTPM || 0) }}

每分钟Token数 @@ -618,6 +618,22 @@ function createUsageTrendChart() { tooltip: { mode: 'index', intersect: false, + itemSort: function(a, b) { + // 按值倒序排列,费用和请求数特殊处理 + const aLabel = a.dataset.label || '' + const bLabel = b.dataset.label || '' + + // 费用和请求数使用不同的轴,单独处理 + if (aLabel === '费用 (USD)' || bLabel === '费用 (USD)') { + return aLabel === '费用 (USD)' ? -1 : 1 + } + if (aLabel === '请求数' || bLabel === '请求数') { + return aLabel === '请求数' ? 1 : -1 + } + + // 其他按token值倒序 + return b.parsed.y - a.parsed.y + }, callbacks: { label: function(context) { const label = context.dataset.label || '' @@ -633,7 +649,14 @@ function createUsageTrendChart() { } else if (label === '请求数') { return label + ': ' + value.toLocaleString() + ' 次' } else { - return label + ': ' + value.toLocaleString() + ' tokens' + // 格式化token数显示 + if (value >= 1000000) { + return label + ': ' + (value / 1000000).toFixed(2) + 'M tokens' + } else if (value >= 1000) { + return label + ': ' + (value / 1000).toFixed(2) + 'K tokens' + } else { + return label + ': ' + value.toLocaleString() + ' tokens' + } } } } @@ -781,12 +804,52 @@ function createApiKeysUsageTrendChart() { tooltip: { mode: 'index', intersect: false, + itemSort: function(a, b) { + // 按值倒序排列 + return b.parsed.y - a.parsed.y + }, callbacks: { label: function(context) { const label = context.dataset.label || '' const value = context.parsed.y - const unit = apiKeysTrendMetric.value === 'tokens' ? ' tokens' : ' 次' - return label + ': ' + value.toLocaleString() + unit + const dataIndex = context.dataIndex + const dataPoint = apiKeysTrendData.value.data[dataIndex] + + // 获取所有数据集在这个时间点的值,用于排名 + const allValues = context.chart.data.datasets.map((dataset, idx) => ({ + value: dataset.data[dataIndex] || 0, + index: idx + })).sort((a, b) => b.value - a.value) + + // 找出当前数据集的排名 + const rank = allValues.findIndex(item => item.index === context.datasetIndex) + 1 + + // 准备排名标识 + let rankIcon = '' + if (rank === 1) rankIcon = '🥇 ' + else if (rank === 2) rankIcon = '🥈 ' + else if (rank === 3) rankIcon = '🥉 ' + + if (apiKeysTrendMetric.value === 'tokens') { + // 格式化token显示 + let formattedValue = '' + if (value >= 1000000) { + formattedValue = (value / 1000000).toFixed(2) + 'M' + } else if (value >= 1000) { + formattedValue = (value / 1000).toFixed(2) + 'K' + } else { + formattedValue = value.toLocaleString() + } + + // 获取对应API Key的费用信息 + const apiKeyId = apiKeysTrendData.value.topApiKeys[context.datasetIndex] + const apiKeyData = dataPoint?.apiKeys?.[apiKeyId] + const cost = apiKeyData?.formattedCost || '$0.00' + + return `${rankIcon}${label}: ${formattedValue} tokens (${cost})` + } else { + return `${rankIcon}${label}: ${value.toLocaleString()} 次` + } } } } From 1ca753c79a98105a7126c76eb2207721420809bd Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 30 Jul 2025 15:17:59 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DAPI=20Keys?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=B6=8B=E5=8A=BF=E5=9B=BE=E8=B4=B9=E7=94=A8?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E4=B8=8D=E5=87=86=E7=A1=AE=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用模型级别的详细统计数据计算费用,而非固定模型 - 按实际使用的模型(sonnet/opus/haiku等)分别计算价格 - 累加各模型费用得到准确的总费用 - 降级方案改用sonnet模型(中等价格)而非haiku(最低价格) 问题原因:之前使用固定的haiku模型计算所有token的费用,导致价格偏低 解决方案:获取模型级别的使用数据,按实际模型价格计算 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/admin.js | 154 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 128 insertions(+), 26 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index 213efd54..f0a06205 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1978,6 +1978,8 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { apiKeys: {} }; + // 先收集基础数据 + const apiKeyDataMap = new Map(); for (const key of keys) { const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/); if (!match) continue; @@ -1992,25 +1994,74 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - // 计算费用 - 使用默认模型价格,因为小时级别的数据没有模型信息 - const usage = { - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_creation_input_tokens: cacheCreateTokens, - cache_read_input_tokens: cacheReadTokens - }; - const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); - - hourData.apiKeys[apiKeyId] = { + apiKeyDataMap.set(apiKeyId, { name: apiKeyMap.get(apiKeyId).name, tokens: totalTokens, requests: parseInt(data.requests) || 0, - cost: costResult.costs.total, - formattedCost: costResult.formatted.total - }; + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }); } } + // 获取该小时的模型级别数据来计算准确费用 + const modelPattern = `usage:*:model:hourly:*:${hourKey}`; + const modelKeys = await client.keys(modelPattern); + const apiKeyCostMap = new Map(); + + for (const modelKey of modelKeys) { + const match = modelKey.match(/usage:(.+?):model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/); + if (!match) continue; + + const apiKeyId = match[1]; + const model = match[2]; + const modelData = await client.hgetall(modelKey); + + if (modelData && apiKeyDataMap.has(apiKeyId)) { + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + }; + + const costResult = CostCalculator.calculateCost(usage, model); + const currentCost = apiKeyCostMap.get(apiKeyId) || 0; + apiKeyCostMap.set(apiKeyId, currentCost + costResult.costs.total); + } + } + + // 组合数据 + for (const [apiKeyId, data] of apiKeyDataMap) { + const cost = apiKeyCostMap.get(apiKeyId) || 0; + + // 如果没有模型级别数据,使用默认模型计算(降级方案) + let finalCost = cost; + let formattedCost = CostCalculator.formatCost(cost); + + if (cost === 0 && data.tokens > 0) { + const usage = { + input_tokens: data.inputTokens, + output_tokens: data.outputTokens, + cache_creation_input_tokens: data.cacheCreateTokens, + cache_read_input_tokens: data.cacheReadTokens + }; + const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022'); + finalCost = fallbackResult.costs.total; + formattedCost = fallbackResult.formatted.total; + } + + hourData.apiKeys[apiKeyId] = { + name: data.name, + tokens: data.tokens, + requests: data.requests, + cost: finalCost, + formattedCost: formattedCost + }; + } + trendData.push(hourData); currentHour.setHours(currentHour.getHours() + 1); } @@ -2035,6 +2086,8 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { apiKeys: {} }; + // 先收集基础数据 + const apiKeyDataMap = new Map(); for (const key of keys) { const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/); if (!match) continue; @@ -2049,25 +2102,74 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; - // 计算费用 - 使用默认模型价格,因为日级别的汇总数据没有模型信息 - const usage = { - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_creation_input_tokens: cacheCreateTokens, - cache_read_input_tokens: cacheReadTokens - }; - const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); - - dayData.apiKeys[apiKeyId] = { + apiKeyDataMap.set(apiKeyId, { name: apiKeyMap.get(apiKeyId).name, tokens: totalTokens, requests: parseInt(data.requests) || 0, - cost: costResult.costs.total, - formattedCost: costResult.formatted.total - }; + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }); } } + // 获取该天的模型级别数据来计算准确费用 + const modelPattern = `usage:*:model:daily:*:${dateStr}`; + const modelKeys = await client.keys(modelPattern); + const apiKeyCostMap = new Map(); + + for (const modelKey of modelKeys) { + const match = modelKey.match(/usage:(.+?):model:daily:(.+?):\d{4}-\d{2}-\d{2}/); + if (!match) continue; + + const apiKeyId = match[1]; + const model = match[2]; + const modelData = await client.hgetall(modelKey); + + if (modelData && apiKeyDataMap.has(apiKeyId)) { + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + }; + + const costResult = CostCalculator.calculateCost(usage, model); + const currentCost = apiKeyCostMap.get(apiKeyId) || 0; + apiKeyCostMap.set(apiKeyId, currentCost + costResult.costs.total); + } + } + + // 组合数据 + for (const [apiKeyId, data] of apiKeyDataMap) { + const cost = apiKeyCostMap.get(apiKeyId) || 0; + + // 如果没有模型级别数据,使用默认模型计算(降级方案) + let finalCost = cost; + let formattedCost = CostCalculator.formatCost(cost); + + if (cost === 0 && data.tokens > 0) { + const usage = { + input_tokens: data.inputTokens, + output_tokens: data.outputTokens, + cache_creation_input_tokens: data.cacheCreateTokens, + cache_read_input_tokens: data.cacheReadTokens + }; + const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022'); + finalCost = fallbackResult.costs.total; + formattedCost = fallbackResult.formatted.total; + } + + dayData.apiKeys[apiKeyId] = { + name: data.name, + tokens: data.tokens, + requests: data.requests, + cost: finalCost, + formattedCost: formattedCost + }; + } + trendData.push(dayData); } } From 7116a6e043d39398dfae734303d0b3c25295d1ea Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 30 Jul 2025 15:36:52 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0UI=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整Element Plus日期选择器宽度为400px,确保时间完整显示 - 重新设计自动刷新控制的样式和布局 - 统一控制栏所有元素的高度,保持视觉一致性 - 使用更精致的开关组件和优化的交互效果 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/components/accounts/AccountForm.vue | 259 ++- .../src/components/accounts/OAuthFlow.vue | 139 +- .../src/components/accounts/ProxyConfig.vue | 47 +- .../components/apikeys/CreateApiKeyModal.vue | 889 ++++---- .../components/apikeys/EditApiKeyModal.vue | 786 ++++--- .../src/components/apikeys/NewApiKeyModal.vue | 164 +- .../components/apikeys/RenewApiKeyModal.vue | 182 +- .../src/components/apistats/ApiKeyInput.vue | 24 +- .../src/components/apistats/LimitConfig.vue | 86 +- .../components/apistats/ModelUsageStats.vue | 73 +- .../src/components/apistats/StatsOverview.vue | 72 +- .../components/apistats/TokenDistribution.vue | 10 +- .../src/components/common/ConfirmDialog.vue | 24 +- .../src/components/common/ConfirmModal.vue | 19 +- .../src/components/common/LogoTitle.vue | 40 +- .../src/components/common/StatCard.vue | 17 +- .../components/common/ToastNotification.vue | 17 +- .../dashboard/ModelDistribution.vue | 55 +- .../src/components/dashboard/UsageTrend.vue | 37 +- .../src/components/layout/AppHeader.vue | 108 +- .../src/components/layout/MainLayout.vue | 15 +- .../src/components/layout/TabBar.vue | 4 +- web/admin-spa/src/views/AccountsView.vue | 401 ++-- web/admin-spa/src/views/ApiKeysView.vue | 389 +++- web/admin-spa/src/views/ApiStatsView.vue | 47 +- web/admin-spa/src/views/DashboardView.vue | 328 +-- web/admin-spa/src/views/LoginView.vue | 57 +- web/admin-spa/src/views/SettingsView.vue | 95 +- web/admin-spa/src/views/TutorialView.vue | 1829 ++++++++++------- 29 files changed, 3869 insertions(+), 2344 deletions(-) diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 2f618a75..21c4590a 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1,36 +1,48 @@