mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 20:37:39 +00:00
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:
@@ -1496,29 +1496,65 @@ router.get('/usage-stats', authenticateAdmin, async (req, res) => {
|
|||||||
// 获取按模型的使用统计和费用
|
// 获取按模型的使用统计和费用
|
||||||
router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { period = 'daily' } = req.query; // daily, monthly
|
const { period = 'daily', startDate, endDate } = req.query; // daily, monthly, 支持自定义时间范围
|
||||||
const today = redis.getDateStringInTimezone();
|
const today = redis.getDateStringInTimezone();
|
||||||
const tzDate = redis.getDateInTimezone();
|
const tzDate = redis.getDateInTimezone();
|
||||||
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
|
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 client = redis.getClientSafe();
|
||||||
|
|
||||||
// 获取所有模型的统计数据
|
// 获取所有模型的统计数据
|
||||||
|
let searchPatterns = [];
|
||||||
|
|
||||||
|
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}`;
|
const pattern = period === 'daily' ? `usage:model:daily:*:${today}` : `usage:model:monthly:*:${currentMonth}`;
|
||||||
logger.info(`📊 Searching pattern: ${pattern}`);
|
searchPatterns = [pattern];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`📊 Searching patterns:`, searchPatterns);
|
||||||
|
|
||||||
|
// 获取所有匹配的keys
|
||||||
|
const allKeys = [];
|
||||||
|
for (const pattern of searchPatterns) {
|
||||||
const keys = await client.keys(pattern);
|
const keys = await client.keys(pattern);
|
||||||
logger.info(`📊 Found ${keys.length} matching keys:`, keys);
|
allKeys.push(...keys);
|
||||||
|
}
|
||||||
|
|
||||||
const modelStats = [];
|
logger.info(`📊 Found ${allKeys.length} matching keys in total`);
|
||||||
|
|
||||||
for (const key of keys) {
|
// 聚合相同模型的数据
|
||||||
const match = key.match(period === 'daily' ?
|
const modelStatsMap = new Map();
|
||||||
/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ :
|
|
||||||
/usage:model:monthly:(.+):\d{4}-\d{2}$/
|
for (const key of allKeys) {
|
||||||
);
|
const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
logger.warn(`📊 Pattern mismatch for key: ${key}`);
|
logger.warn(`📊 Pattern mismatch for key: ${key}`);
|
||||||
@@ -1528,14 +1564,36 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
const model = match[1];
|
const model = match[1];
|
||||||
const data = await client.hgetall(key);
|
const data = await client.hgetall(key);
|
||||||
|
|
||||||
logger.info(`📊 Model ${model} data:`, data);
|
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const stats = modelStatsMap.get(model) || {
|
||||||
|
requests: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
allTokens: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
modelStatsMap.set(model, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为数组并计算费用
|
||||||
|
const modelStats = [];
|
||||||
|
|
||||||
|
for (const [model, stats] of modelStatsMap) {
|
||||||
const usage = {
|
const usage = {
|
||||||
input_tokens: parseInt(data.inputTokens) || 0,
|
input_tokens: stats.inputTokens,
|
||||||
output_tokens: parseInt(data.outputTokens) || 0,
|
output_tokens: stats.outputTokens,
|
||||||
cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0,
|
cache_creation_input_tokens: stats.cacheCreateTokens,
|
||||||
cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0
|
cache_read_input_tokens: stats.cacheReadTokens
|
||||||
};
|
};
|
||||||
|
|
||||||
// 计算费用
|
// 计算费用
|
||||||
@@ -1543,15 +1601,15 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
modelStats.push({
|
modelStats.push({
|
||||||
model,
|
model,
|
||||||
period,
|
period: startDate && endDate ? 'custom' : period,
|
||||||
requests: parseInt(data.requests) || 0,
|
requests: stats.requests,
|
||||||
inputTokens: usage.input_tokens,
|
inputTokens: usage.input_tokens,
|
||||||
outputTokens: usage.output_tokens,
|
outputTokens: usage.output_tokens,
|
||||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||||
cacheReadTokens: usage.cache_read_input_tokens,
|
cacheReadTokens: usage.cache_read_input_tokens,
|
||||||
allTokens: parseInt(data.allTokens) || 0,
|
allTokens: stats.allTokens,
|
||||||
usage: {
|
usage: {
|
||||||
requests: parseInt(data.requests) || 0,
|
requests: stats.requests,
|
||||||
inputTokens: usage.input_tokens,
|
inputTokens: usage.input_tokens,
|
||||||
outputTokens: usage.output_tokens,
|
outputTokens: usage.output_tokens,
|
||||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||||
@@ -1563,7 +1621,6 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
pricing: costData.pricing
|
pricing: costData.pricing
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 按总费用排序
|
// 按总费用排序
|
||||||
modelStats.sort((a, b) => b.costs.total - a.costs.total);
|
modelStats.sort((a, b) => b.costs.total - a.costs.total);
|
||||||
|
|||||||
@@ -263,7 +263,57 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
|
|
||||||
async function loadModelStats(period = 'daily') {
|
async function loadModelStats(period = 'daily') {
|
||||||
try {
|
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) {
|
if (response.success) {
|
||||||
dashboardModelStats.value = response.data
|
dashboardModelStats.value = response.data
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user