From 321be986a6b1fe61bd592973873f300d64368fa2 Mon Sep 17 00:00:00 2001
From: shaw
Date: Wed, 30 Jul 2025 15:07:05 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=AA=E8=A1=A8?=
=?UTF-8?q?=E7=9B=98=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA=E5=92=8C=E7=94=A8?=
=?UTF-8?q?=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()} 次`
+ }
}
}
}