feat: 优化仪表盘数据展示和用户体验

- 优化实时TPM显示格式,添加智能单位(K/M)
- 折线图tooltip添加token数单位格式化
- API Keys使用趋势图增加USD金额计算和显示
- 实现鼠标悬浮时数据倒序展示,前3名添加🥇🥈🥉标识
- 后端API添加费用计算支持
- 删除测试脚本,保持代码整洁

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-30 15:07:05 +08:00
parent 21461863af
commit 321be986a6
4 changed files with 102 additions and 84 deletions

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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
};
}
}

View File

@@ -176,7 +176,7 @@
实时TPM
<span class="text-xs text-gray-400">({{ dashboardData.metricsWindow }}分钟)</span>
</p>
<p class="text-3xl font-bold text-rose-600">{{ dashboardData.realtimeTPM || 0 }}</p>
<p class="text-3xl font-bold text-rose-600">{{ formatNumber(dashboardData.realtimeTPM || 0) }}</p>
<p class="text-xs text-gray-500 mt-1">
每分钟Token数
<span v-if="dashboardData.isHistoricalMetrics" class="text-yellow-600">
@@ -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()}`
}
}
}
}