fix: 修复仪表盘页面时间筛选功能

- 修复模型使用分布和详细统计数据不响应时间筛选的问题
- 后端/admin/model-stats端点添加startDate和endDate参数支持
- 支持自定义时间范围的模型统计数据聚合
- 前端loadModelStats函数添加时间范围参数传递
- 修复小时粒度和自定义时间筛选不生效的问题

现在时间筛选可以正确控制所有数据展示:
- Token使用趋势图 ✓
- 模型使用分布 ✓
- 详细统计数据 ✓
- API Keys使用趋势 ✓

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-08-03 10:03:08 +08:00
parent 495c458e9a
commit 329904ba72
2 changed files with 147 additions and 40 deletions

View File

@@ -1496,29 +1496,65 @@ router.get('/usage-stats', authenticateAdmin, async (req, res) => {
// 获取按模型的使用统计和费用
router.get('/model-stats', authenticateAdmin, async (req, res) => {
try {
const { period = 'daily' } = req.query; // daily, monthly
const { period = 'daily', startDate, endDate } = req.query; // daily, monthly, 支持自定义时间范围
const today = redis.getDateStringInTimezone();
const tzDate = redis.getDateInTimezone();
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
logger.info(`📊 Getting global model stats, period: ${period}, today: ${today}, currentMonth: ${currentMonth}`);
logger.info(`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`);
const client = redis.getClientSafe();
// 获取所有模型的统计数据
const pattern = period === 'daily' ? `usage:model:daily:*:${today}` : `usage:model:monthly:*:${currentMonth}`;
logger.info(`📊 Searching pattern: ${pattern}`);
let searchPatterns = [];
const keys = await client.keys(pattern);
logger.info(`📊 Found ${keys.length} matching keys:`, keys);
if (startDate && endDate) {
// 自定义日期范围,生成多个日期的搜索模式
const start = new Date(startDate);
const end = new Date(endDate);
// 确保日期范围有效
if (start > end) {
return res.status(400).json({ error: 'Start date must be before or equal to end date' });
}
// 限制最大范围为31天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
if (daysDiff > 31) {
return res.status(400).json({ error: 'Date range cannot exceed 31 days' });
}
// 生成日期范围内所有日期的搜索模式
const currentDate = new Date(start);
while (currentDate <= end) {
const dateStr = redis.getDateStringInTimezone(currentDate);
searchPatterns.push(`usage:model:daily:*:${dateStr}`);
currentDate.setDate(currentDate.getDate() + 1);
}
logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`);
} else {
// 使用默认的period
const pattern = period === 'daily' ? `usage:model:daily:*:${today}` : `usage:model:monthly:*:${currentMonth}`;
searchPatterns = [pattern];
}
const modelStats = [];
logger.info(`📊 Searching patterns:`, searchPatterns);
for (const key of keys) {
const match = key.match(period === 'daily' ?
/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ :
/usage:model:monthly:(.+):\d{4}-\d{2}$/
);
// 获取所有匹配的keys
const allKeys = [];
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern);
allKeys.push(...keys);
}
logger.info(`📊 Found ${allKeys.length} matching keys in total`);
// 聚合相同模型的数据
const modelStatsMap = new Map();
for (const key of allKeys) {
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/);
if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`);
@@ -1528,41 +1564,62 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
const model = match[1];
const data = await client.hgetall(key);
logger.info(`📊 Model ${model} data:`, data);
if (data && Object.keys(data).length > 0) {
const usage = {
input_tokens: parseInt(data.inputTokens) || 0,
output_tokens: parseInt(data.outputTokens) || 0,
cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0,
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
const stats = modelStatsMap.get(model) || {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0
};
// 计算费用
const costData = CostCalculator.calculateCost(usage, model);
stats.requests += parseInt(data.requests) || 0;
stats.inputTokens += parseInt(data.inputTokens) || 0;
stats.outputTokens += parseInt(data.outputTokens) || 0;
stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0;
stats.allTokens += parseInt(data.allTokens) || 0;
modelStats.push({
model,
period,
requests: parseInt(data.requests) || 0,
modelStatsMap.set(model, stats);
}
}
// 转换为数组并计算费用
const modelStats = [];
for (const [model, stats] of modelStatsMap) {
const usage = {
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
};
// 计算费用
const costData = CostCalculator.calculateCost(usage, model);
modelStats.push({
model,
period: startDate && endDate ? 'custom' : period,
requests: stats.requests,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
allTokens: stats.allTokens,
usage: {
requests: stats.requests,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
allTokens: parseInt(data.allTokens) || 0,
usage: {
requests: parseInt(data.requests) || 0,
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
cacheCreateTokens: usage.cache_creation_input_tokens,
cacheReadTokens: usage.cache_read_input_tokens,
totalTokens: usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
},
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
});
}
totalTokens: usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
},
costs: costData.costs,
formatted: costData.formatted,
pricing: costData.pricing
});
}
// 按总费用排序

View File

@@ -263,7 +263,57 @@ export const useDashboardStore = defineStore('dashboard', () => {
async function loadModelStats(period = 'daily') {
try {
const response = await apiClient.get(`/admin/model-stats?period=${period}`)
let url = `/admin/model-stats?period=${period}`
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') {
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 将系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
const systemTz = 8
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
const utcDate = new Date(Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds))
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
} else if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') {
// 小时粒度的预设时间范围
const now = new Date()
let startTime, endTime
switch (dateFilter.value.preset) {
case 'last24h':
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case 'yesterday':
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startTime = getSystemTimezoneDay(yesterday, true)
endTime = getSystemTimezoneDay(yesterday, false)
break
case 'dayBefore':
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startTime = getSystemTimezoneDay(dayBefore, true)
endTime = getSystemTimezoneDay(dayBefore, false)
break
default:
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
}
}
const response = await apiClient.get(url)
if (response.success) {
dashboardModelStats.value = response.data
}