feat: 改进管理界面弹窗体验和滚动条美化

- 修复API Key创建/编辑弹窗和账户信息修改弹窗在低高度屏幕上被遮挡的问题
- 为所有弹窗添加自适应高度支持,最大高度限制为90vh
- 美化Claude账户弹窗的滚动条样式,使用紫蓝渐变色与主题保持一致
- 添加响应式适配,移动设备上弹窗高度调整为85vh
- 优化滚动条交互体验,支持悬停和激活状态

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-18 23:49:55 +08:00
parent 6988be0806
commit f5968e518e
9 changed files with 1148 additions and 96 deletions

View File

@@ -270,8 +270,7 @@ class Application {
logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`);
});
// 设置服务器超时时间,与代理超时时间一致
const serverTimeout = config.proxy.timeout || 300000; // 默认5分钟
const serverTimeout = 600000; // 默认10分钟
this.server.timeout = serverTimeout;
this.server.keepAliveTimeout = serverTimeout + 5000; // keepAlive 稍长一点
logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout/1000}s)`);

View File

@@ -139,18 +139,24 @@ class RedisClient {
// 📊 使用统计相关操作支持缓存token统计和模型信息
async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
const key = `usage:${keyId}`;
const today = new Date().toISOString().split('T')[0];
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
const now = new Date();
const today = now.toISOString().split('T')[0];
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const currentHour = `${today}:${String(now.getHours()).padStart(2, '0')}`; // 新增小时级别
const daily = `usage:daily:${keyId}:${today}`;
const monthly = `usage:monthly:${keyId}:${currentMonth}`;
const hourly = `usage:hourly:${keyId}:${currentHour}`; // 新增小时级别key
// 按模型统计的键
const modelDaily = `usage:model:daily:${model}:${today}`;
const modelMonthly = `usage:model:monthly:${model}:${currentMonth}`;
const modelHourly = `usage:model:hourly:${model}:${currentHour}`; // 新增模型小时级别
// API Key级别的模型统计
const keyModelDaily = `usage:${keyId}:model:daily:${model}:${today}`;
const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`;
const keyModelHourly = `usage:${keyId}:model:hourly:${model}:${currentHour}`; // 新增API Key模型小时级别
// 智能处理输入输出token分配
const finalInputTokens = inputTokens || 0;
@@ -218,13 +224,40 @@ class RedisClient {
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(keyModelMonthly, 86400 * 365), // API Key模型每月统计1年过期
this.client.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期
]);
}

View File

@@ -351,6 +351,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
const activeApiKeys = apiKeys.filter(key => key.isActive).length;
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length;
const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length;
const dashboard = {
overview: {
@@ -358,6 +359,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
activeApiKeys,
totalClaudeAccounts: accounts.length,
activeClaudeAccounts: activeAccounts,
rateLimitedClaudeAccounts: rateLimitedAccounts,
totalTokensUsed,
totalRequestsUsed,
totalInputTokensUsed,
@@ -528,11 +530,129 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => {
// 获取使用趋势数据
router.get('/usage-trend', authenticateAdmin, async (req, res) => {
try {
const { days = 7 } = req.query;
const daysCount = parseInt(days) || 7;
const { days = 7, granularity = 'day', startDate, endDate } = req.query;
const client = redis.getClientSafe();
const trendData = [];
if (granularity === 'hour') {
// 小时粒度统计
let startTime, endTime;
if (startDate && endDate) {
// 使用自定义时间范围
startTime = new Date(startDate);
endTime = new Date(endDate);
} else {
// 默认最近24小时
endTime = new Date();
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
}
// 确保时间范围不超过24小时
const timeDiff = endTime - startTime;
if (timeDiff > 24 * 60 * 60 * 1000) {
return res.status(400).json({
error: '小时粒度查询时间范围不能超过24小时'
});
}
// 按小时遍历
const currentHour = new Date(startTime);
currentHour.setMinutes(0, 0, 0);
while (currentHour <= endTime) {
const dateStr = currentHour.toISOString().split('T')[0];
const hour = String(currentHour.getHours()).padStart(2, '0');
const hourKey = `${dateStr}:${hour}`;
// 获取当前小时的模型统计数据
const modelPattern = `usage:model:hourly:*:${hourKey}`;
const modelKeys = await client.keys(modelPattern);
let hourInputTokens = 0;
let hourOutputTokens = 0;
let hourRequests = 0;
let hourCacheCreateTokens = 0;
let hourCacheReadTokens = 0;
let hourCost = 0;
for (const modelKey of modelKeys) {
const modelMatch = modelKey.match(/usage:model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/);
if (!modelMatch) continue;
const model = modelMatch[1];
const data = await client.hgetall(modelKey);
if (data && Object.keys(data).length > 0) {
const modelInputTokens = parseInt(data.inputTokens) || 0;
const modelOutputTokens = parseInt(data.outputTokens) || 0;
const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0;
const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0;
const modelRequests = parseInt(data.requests) || 0;
hourInputTokens += modelInputTokens;
hourOutputTokens += modelOutputTokens;
hourCacheCreateTokens += modelCacheCreateTokens;
hourCacheReadTokens += modelCacheReadTokens;
hourRequests += modelRequests;
const modelUsage = {
input_tokens: modelInputTokens,
output_tokens: modelOutputTokens,
cache_creation_input_tokens: modelCacheCreateTokens,
cache_read_input_tokens: modelCacheReadTokens
};
const modelCostResult = CostCalculator.calculateCost(modelUsage, model);
hourCost += modelCostResult.costs.total;
}
}
// 如果没有模型级别的数据尝试API Key级别的数据
if (modelKeys.length === 0) {
const pattern = `usage:hourly:*:${hourKey}`;
const keys = await client.keys(pattern);
for (const key of keys) {
const data = await client.hgetall(key);
if (data) {
hourInputTokens += parseInt(data.inputTokens) || 0;
hourOutputTokens += parseInt(data.outputTokens) || 0;
hourRequests += parseInt(data.requests) || 0;
hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0;
}
}
const usage = {
input_tokens: hourInputTokens,
output_tokens: hourOutputTokens,
cache_creation_input_tokens: hourCacheCreateTokens,
cache_read_input_tokens: hourCacheReadTokens
};
const costResult = CostCalculator.calculateCost(usage, 'unknown');
hourCost = costResult.costs.total;
}
trendData.push({
date: hourKey,
hour: currentHour.toISOString(),
inputTokens: hourInputTokens,
outputTokens: hourOutputTokens,
requests: hourRequests,
cacheCreateTokens: hourCacheCreateTokens,
cacheReadTokens: hourCacheReadTokens,
totalTokens: hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens,
cost: hourCost
});
// 移到下一个小时
currentHour.setHours(currentHour.getHours() + 1);
}
} else {
// 天粒度统计(保持原有逻辑)
const daysCount = parseInt(days) || 7;
const today = new Date();
// 获取过去N天的数据
@@ -553,7 +673,7 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
let dayCost = 0;
// 按模型统计使用量
const modelUsageMap = new Map();
// const modelUsageMap = new Map();
// 获取当天所有模型的使用数据
const modelPattern = `usage:model:daily:*:${dateStr}`;
@@ -630,10 +750,16 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
});
}
// 按日期正序排列
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
}
res.json({ success: true, data: trendData });
// 按日期正序排列
if (granularity === 'hour') {
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour));
} else {
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
}
res.json({ success: true, data: trendData, granularity });
} catch (error) {
logger.error('❌ Failed to get usage trend:', error);
res.status(500).json({ error: 'Failed to get usage trend', message: error.message });
@@ -833,6 +959,152 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
});
// 获取按API Key分组的使用趋势
router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
try {
const { granularity = 'day', days = 7, startDate, endDate } = req.query;
logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`);
const client = redis.getClientSafe();
const trendData = [];
// 获取所有API Keys
const apiKeys = await apiKeyService.getAllApiKeys();
const apiKeyMap = new Map(apiKeys.map(key => [key.id, key]));
if (granularity === 'hour') {
// 小时粒度统计
let endTime, startTime;
if (startDate && endDate) {
// 自定义时间范围
startTime = new Date(startDate);
endTime = new Date(endDate);
} else {
// 默认近24小时
endTime = new Date();
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
}
// 按小时遍历
const currentHour = new Date(startTime);
currentHour.setMinutes(0, 0, 0);
while (currentHour <= endTime) {
const hourKey = currentHour.toISOString().split(':')[0].replace('T', ':');
// 获取这个小时所有API Key的数据
const pattern = `usage:hourly:*:${hourKey}`;
const keys = await client.keys(pattern);
const hourData = {
hour: currentHour.toISOString(),
apiKeys: {}
};
for (const key of keys) {
const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/);
if (!match) continue;
const apiKeyId = match[1];
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);
hourData.apiKeys[apiKeyId] = {
name: apiKeyMap.get(apiKeyId).name,
tokens: totalTokens
};
}
}
trendData.push(hourData);
currentHour.setHours(currentHour.getHours() + 1);
}
} else {
// 天粒度统计
const daysCount = parseInt(days) || 7;
const today = new Date();
// 获取过去N天的数据
for (let i = 0; i < daysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
// 获取这一天所有API Key的数据
const pattern = `usage:daily:*:${dateStr}`;
const keys = await client.keys(pattern);
const dayData = {
date: dateStr,
apiKeys: {}
};
for (const key of keys) {
const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/);
if (!match) continue;
const apiKeyId = match[1];
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);
dayData.apiKeys[apiKeyId] = {
name: apiKeyMap.get(apiKeyId).name,
tokens: totalTokens
};
}
}
trendData.push(dayData);
}
}
// 按时间正序排列
if (granularity === 'hour') {
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour));
} else {
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
}
// 计算每个API Key的总token数用于排序
const apiKeyTotals = new Map();
for (const point of trendData) {
for (const [apiKeyId, data] of Object.entries(point.apiKeys)) {
apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens);
}
}
// 获取前10个使用量最多的API Key
const topApiKeys = Array.from(apiKeyTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([apiKeyId]) => apiKeyId);
res.json({
success: true,
data: trendData,
granularity,
topApiKeys,
totalApiKeys: apiKeyTotals.size
});
} catch (error) {
logger.error('❌ Failed to get API keys usage trend:', error);
res.status(500).json({ error: 'Failed to get API keys usage trend', message: error.message });
}
});
// 计算总体使用费用
router.get('/usage-costs', authenticateAdmin, async (req, res) => {
try {

View File

@@ -217,7 +217,7 @@ router.post('/auth/change-password', async (req, res) => {
try {
const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8'));
const oldData = { ...initData }; // 备份旧数据
// const oldData = { ...initData }; // 备份旧数据
// 更新 init.json
initData.adminUsername = updatedUsername;
@@ -252,12 +252,12 @@ router.post('/auth/change-password', async (req, res) => {
// 清除当前会话(强制用户重新登录)
await redis.deleteSession(token);
logger.success(`🔐 Admin password changed successfully for user: ${updatedAdminData.username}`);
logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`);
res.json({
success: true,
message: 'Password changed successfully. Please login again.',
newUsername: updatedAdminData.username
newUsername: updatedUsername
});
} catch (error) {

View File

@@ -228,8 +228,12 @@ class ClaudeAccountService {
try {
const accounts = await redis.getAllClaudeAccounts();
// 处理返回数据,移除敏感信息
return accounts.map(account => ({
// 处理返回数据,移除敏感信息并添加限流状态
const processedAccounts = await Promise.all(accounts.map(async account => {
// 获取限流状态信息
const rateLimitInfo = await this.getAccountRateLimitInfo(account.id);
return {
id: account.id,
name: account.name,
description: account.description,
@@ -242,8 +246,17 @@ class ClaudeAccountService {
createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
expiresAt: account.expiresAt
expiresAt: account.expiresAt,
// 添加限流状态信息
rateLimitStatus: rateLimitInfo ? {
isRateLimited: rateLimitInfo.isRateLimited,
rateLimitedAt: rateLimitInfo.rateLimitedAt,
minutesRemaining: rateLimitInfo.minutesRemaining
} : null
};
}));
return processedAccounts;
} catch (error) {
logger.error('❌ Failed to get Claude accounts:', error);
throw error;
@@ -405,8 +418,15 @@ class ClaudeAccountService {
// 验证映射的账户是否仍然在共享池中且可用
const mappedAccount = sharedAccounts.find(acc => acc.id === mappedAccountId);
if (mappedAccount) {
// 如果映射的账户被限流了,删除映射并重新选择
const isRateLimited = await this.isAccountRateLimited(mappedAccountId);
if (isRateLimited) {
logger.warn(`⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account`);
await redis.deleteSessionAccountMapping(sessionHash);
} else {
logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`);
return mappedAccountId;
}
} else {
logger.warn(`⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account`);
// 清理无效的映射
@@ -415,21 +435,54 @@ class ClaudeAccountService {
}
}
// 从共享池选择账户(负载均衡)
const sortedAccounts = sharedAccounts.sort((a, b) => {
// 将账户分为限流和非限流两组
const nonRateLimitedAccounts = [];
const rateLimitedAccounts = [];
for (const account of sharedAccounts) {
const isRateLimited = await this.isAccountRateLimited(account.id);
if (isRateLimited) {
const rateLimitInfo = await this.getAccountRateLimitInfo(account.id);
account._rateLimitInfo = rateLimitInfo; // 临时存储限流信息
rateLimitedAccounts.push(account);
} else {
nonRateLimitedAccounts.push(account);
}
}
// 优先从非限流账户中选择
let candidateAccounts = nonRateLimitedAccounts;
// 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先)
if (candidateAccounts.length === 0) {
logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool');
candidateAccounts = rateLimitedAccounts.sort((a, b) => {
const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime();
const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime();
return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先
});
} else {
// 非限流账户按最近刷新时间排序
candidateAccounts = candidateAccounts.sort((a, b) => {
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
return bLastRefresh - aLastRefresh;
});
const selectedAccountId = sortedAccounts[0].id;
}
if (candidateAccounts.length === 0) {
throw new Error('No available shared Claude accounts');
}
const selectedAccountId = candidateAccounts[0].id;
// 如果有会话哈希,建立新的映射
if (sessionHash) {
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期
logger.info(`🎯 Created new sticky session mapping for shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
logger.info(`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
}
logger.info(`🎯 Selected shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`);
logger.info(`🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`);
return selectedAccountId;
} catch (error) {
logger.error('❌ Failed to select account for API key:', error);
@@ -570,6 +623,118 @@ class ClaudeAccountService {
return 0;
}
}
// 🚫 标记账号为限流状态
async markAccountRateLimited(accountId, sessionHash = null) {
try {
const accountData = await redis.getClaudeAccount(accountId);
if (!accountData || Object.keys(accountData).length === 0) {
throw new Error('Account not found');
}
// 设置限流状态和时间
accountData.rateLimitedAt = new Date().toISOString();
accountData.rateLimitStatus = 'limited';
await redis.setClaudeAccount(accountId, accountData);
// 如果有会话哈希,删除粘性会话映射
if (sessionHash) {
await redis.deleteSessionAccountMapping(sessionHash);
logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`);
}
logger.warn(`🚫 Account marked as rate limited: ${accountData.name} (${accountId})`);
return { success: true };
} catch (error) {
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error);
throw error;
}
}
// ✅ 移除账号的限流状态
async removeAccountRateLimit(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId);
if (!accountData || Object.keys(accountData).length === 0) {
throw new Error('Account not found');
}
// 清除限流状态
delete accountData.rateLimitedAt;
delete accountData.rateLimitStatus;
await redis.setClaudeAccount(accountId, accountData);
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`);
return { success: true };
} catch (error) {
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error);
throw error;
}
}
// 🔍 检查账号是否处于限流状态
async isAccountRateLimited(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId);
if (!accountData || Object.keys(accountData).length === 0) {
return false;
}
// 检查是否有限流状态
if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) {
const rateLimitedAt = new Date(accountData.rateLimitedAt);
const now = new Date();
const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60);
// 如果限流超过1小时自动解除
if (hoursSinceRateLimit >= 1) {
await this.removeAccountRateLimit(accountId);
return false;
}
return true;
}
return false;
} catch (error) {
logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error);
return false;
}
}
// 📊 获取账号的限流信息
async getAccountRateLimitInfo(accountId) {
try {
const accountData = await redis.getClaudeAccount(accountId);
if (!accountData || Object.keys(accountData).length === 0) {
return null;
}
if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) {
const rateLimitedAt = new Date(accountData.rateLimitedAt);
const now = new Date();
const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60));
const minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit);
return {
isRateLimited: minutesRemaining > 0,
rateLimitedAt: accountData.rateLimitedAt,
minutesSinceRateLimit,
minutesRemaining
};
}
return {
isRateLimited: false,
rateLimitedAt: null,
minutesSinceRateLimit: 0,
minutesRemaining: 0
};
} catch (error) {
logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error);
return null;
}
}
}
module.exports = new ClaudeAccountService();

View File

@@ -72,6 +72,35 @@ class ClaudeRelayService {
clientResponse.removeListener('close', handleClientDisconnect);
}
// 检查响应是否为限流错误
if (response.statusCode !== 200 && response.statusCode !== 201) {
let isRateLimited = false;
try {
const responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;
if (responseBody && responseBody.error && responseBody.error.message &&
responseBody.error.message.toLowerCase().includes('exceed your account\'s rate limit')) {
isRateLimited = true;
}
} catch (e) {
// 如果解析失败,检查原始字符串
if (response.body && response.body.toLowerCase().includes('exceed your account\'s rate limit')) {
isRateLimited = true;
}
}
if (isRateLimited) {
logger.warn(`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`);
// 标记账号为限流状态并删除粘性会话映射
await claudeAccountService.markAccountRateLimited(accountId, sessionHash);
}
} else if (response.statusCode === 200 || response.statusCode === 201) {
// 如果请求成功,检查并移除限流状态
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId);
if (isRateLimited) {
await claudeAccountService.removeAccountRateLimit(accountId);
}
}
// 记录成功的API调用
const inputTokens = requestBody.messages ?
requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 : 0; // 粗略估算
@@ -408,7 +437,7 @@ class ClaudeRelayService {
const proxyAgent = await this._getProxyAgent(accountId);
// 发送流式请求并捕获usage数据
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback);
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash);
} catch (error) {
logger.error('❌ Claude stream relay with usage capture failed:', error);
throw error;
@@ -416,7 +445,7 @@ class ClaudeRelayService {
}
// 🌊 发送流式请求到Claude API带usage数据捕获
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback) {
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash) {
return new Promise((resolve, reject) => {
const url = new URL(this.claudeApiUrl);
@@ -457,6 +486,7 @@ class ClaudeRelayService {
let buffer = '';
let finalUsageReported = false; // 防止重复统计的标志
let collectedUsageData = {}; // 收集来自不同事件的usage数据
let rateLimitDetected = false; // 限流检测标志
// 监听数据块解析SSE并寻找usage信息
res.on('data', (chunk) => {
@@ -517,6 +547,13 @@ class ClaudeRelayService {
}
}
// 检查是否有限流错误
if (data.type === 'error' && data.error && data.error.message &&
data.error.message.toLowerCase().includes('exceed your account\'s rate limit')) {
rateLimitDetected = true;
logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`);
}
} catch (parseError) {
// 忽略JSON解析错误继续处理
logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100));
@@ -525,7 +562,7 @@ class ClaudeRelayService {
}
});
res.on('end', () => {
res.on('end', async () => {
// 处理缓冲区中剩余的数据
if (buffer.trim()) {
responseStream.write(buffer);
@@ -537,6 +574,18 @@ class ClaudeRelayService {
logger.warn('⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.');
}
// 处理限流状态
if (rateLimitDetected || res.statusCode === 429) {
// 标记账号为限流状态并删除粘性会话映射
await claudeAccountService.markAccountRateLimited(accountId, sessionHash);
} else if (res.statusCode === 200) {
// 如果请求成功,检查并移除限流状态
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId);
if (isRateLimited) {
await claudeAccountService.removeAccountRateLimit(accountId);
}
}
logger.debug('🌊 Claude stream response with usage capture completed');
resolve();
});

View File

@@ -77,6 +77,15 @@ const app = createApp({
usageTrendChart: null,
trendPeriod: 7,
trendData: [],
trendGranularity: 'day', // 新增趋势图粒度day/hour
// API Keys 使用趋势
apiKeysUsageTrendChart: null,
apiKeysTrendData: {
data: [],
topApiKeys: [],
totalApiKeys: 0
},
// 统一的日期筛选
dateFilter: {
@@ -91,6 +100,10 @@ const app = createApp({
{ value: '30days', label: '近30天', days: 30 }
]
},
defaultTime: [
new Date(2000, 1, 1, 0, 0, 0),
new Date(2000, 2, 1, 23, 59, 59),
],
showDateRangePicker: false, // 日期范围选择器显示状态
dateRangeInputValue: '', // 日期范围显示文本
@@ -247,8 +260,11 @@ const app = createApp({
// 初始化日期筛选器和图表数据
this.initializeDateFilter();
// 预加载账号列表,以便在API Keys页面能正确显示绑定账号名称
this.loadAccounts().then(() => {
// 预加载账号列表API Keys,以便正确显示绑定关系
Promise.all([
this.loadAccounts(),
this.loadApiKeys()
]).then(() => {
// 根据当前活跃标签页加载数据
this.loadCurrentTabData();
});
@@ -257,6 +273,7 @@ const app = createApp({
this.waitForChartJS().then(() => {
this.loadDashboardModelStats();
this.loadUsageTrend();
this.loadApiKeysUsageTrend();
});
}
} else {
@@ -422,6 +439,10 @@ const app = createApp({
// 验证账户类型切换
if (this.editAccountForm.accountType === 'shared' &&
this.editAccountForm.originalAccountType === 'dedicated') {
// 确保API Keys数据已加载以便正确计算绑定数量
if (this.apiKeys.length === 0) {
await this.loadApiKeys();
}
const boundKeysCount = this.getBoundApiKeysCount(this.editAccountForm.id);
if (boundKeysCount > 0) {
this.showToast(`无法切换到共享账户,该账户绑定了 ${boundKeysCount} 个API Key请先解绑所有API Key`, 'error', '切换失败');
@@ -756,6 +777,7 @@ const app = createApp({
this.waitForChartJS().then(() => {
this.loadDashboardModelStats();
this.loadUsageTrend();
this.loadApiKeysUsageTrend();
});
break;
case 'apiKeys':
@@ -766,7 +788,11 @@ const app = createApp({
]);
break;
case 'accounts':
this.loadAccounts();
// 加载账户时同时加载API Keys以便正确计算绑定数量
Promise.all([
this.loadAccounts(),
this.loadApiKeys()
]);
break;
case 'models':
this.loadModelStats();
@@ -819,6 +845,19 @@ const app = createApp({
}
this.usageTrendChart = null;
}
// 清理API Keys使用趋势图表
if (this.apiKeysUsageTrendChart) {
try {
// 先停止所有动画
this.apiKeysUsageTrendChart.stop();
// 再销毁图表
this.apiKeysUsageTrendChart.destroy();
} catch (error) {
console.warn('Error destroying API keys usage trend chart:', error);
}
this.apiKeysUsageTrendChart = null;
}
},
// 检查DOM元素是否存在且有效
@@ -1017,6 +1056,7 @@ const app = createApp({
activeApiKeys: overview.activeApiKeys || 0,
totalAccounts: overview.totalClaudeAccounts || 0,
activeAccounts: overview.activeClaudeAccounts || 0,
rateLimitedAccounts: overview.rateLimitedClaudeAccounts || 0,
todayRequests: recentActivity.requestsToday || 0,
totalRequests: overview.totalRequestsUsed || 0,
todayTokens: recentActivity.tokensToday || 0,
@@ -1263,6 +1303,11 @@ const app = createApp({
},
async deleteAccount(accountId) {
// 确保API Keys数据已加载以便正确计算绑定数量
if (this.apiKeys.length === 0) {
await this.loadApiKeys();
}
// 检查是否有API Key绑定到此账号
const boundKeysCount = this.getBoundApiKeysCount(accountId);
if (boundKeysCount > 0) {
@@ -1529,11 +1574,68 @@ const app = createApp({
await this.loadUsageTrend();
},
// 加载API Keys使用趋势数据
async loadApiKeysUsageTrend() {
console.log('Loading API keys usage trend data, granularity:', this.trendGranularity);
try {
let url = '/admin/api-keys-usage-trend?';
if (this.trendGranularity === 'hour') {
// 小时粒度,传递开始和结束时间
url += `granularity=hour`;
if (this.dateFilter.customRange && this.dateFilter.customRange.length === 2) {
url += `&startDate=${encodeURIComponent(this.dateFilter.customRange[0])}`;
url += `&endDate=${encodeURIComponent(this.dateFilter.customRange[1])}`;
}
} else {
// 天粒度,传递天数
url += `granularity=day&days=${this.trendPeriod}`;
}
const response = await fetch(url, {
headers: { 'Authorization': 'Bearer ' + this.authToken }
});
if (!response.ok) {
console.error('API keys usage trend API error:', response.status, response.statusText);
return;
}
const data = await response.json();
if (data.success) {
this.apiKeysTrendData = {
data: data.data || [],
topApiKeys: data.topApiKeys || [],
totalApiKeys: data.totalApiKeys || 0
};
console.log('Loaded API keys trend data:', this.apiKeysTrendData);
this.updateApiKeysUsageTrendChart();
}
} catch (error) {
console.error('Failed to load API keys usage trend:', error);
}
},
// 加载使用趋势数据
async loadUsageTrend() {
console.log('Loading usage trend data, period:', this.trendPeriod, 'authToken:', !!this.authToken);
console.log('Loading usage trend data, period:', this.trendPeriod, 'granularity:', this.trendGranularity, 'authToken:', !!this.authToken);
try {
const response = await fetch('/admin/usage-trend?days=' + this.trendPeriod, {
let url = '/admin/usage-trend?';
if (this.trendGranularity === 'hour') {
// 小时粒度,传递开始和结束时间
url += `granularity=hour`;
if (this.dateFilter.customRange && this.dateFilter.customRange.length === 2) {
url += `&startDate=${encodeURIComponent(this.dateFilter.customRange[0])}`;
url += `&endDate=${encodeURIComponent(this.dateFilter.customRange[1])}`;
}
} else {
// 天粒度,传递天数
url += `granularity=day&days=${this.trendPeriod}`;
}
const response = await fetch(url, {
headers: { 'Authorization': 'Bearer ' + this.authToken }
});
@@ -1601,7 +1703,23 @@ const app = createApp({
return;
}
const labels = this.trendData.map(item => item.date);
// 根据粒度格式化标签
const labels = this.trendData.map(item => {
if (this.trendGranularity === 'hour') {
// 小时粒度从hour字段提取时间
if (item.hour) {
const date = new Date(item.hour);
return `${String(date.getHours()).padStart(2, '0')}:00`;
}
// 后备方案从date字段解析
const [, time] = item.date.split(':');
return `${time}:00`;
} else {
// 天粒度:显示日期
return item.date;
}
});
const inputData = this.trendData.map(item => item.inputTokens || 0);
const outputData = this.trendData.map(item => item.outputTokens || 0);
const cacheCreateData = this.trendData.map(item => item.cacheCreateTokens || 0);
@@ -1676,6 +1794,19 @@ const app = createApp({
intersect: false,
},
scales: {
x: {
type: 'category',
display: true,
title: {
display: true,
text: this.trendGranularity === 'hour' ? '时间' : '日期'
},
ticks: {
autoSkip: true,
maxRotation: this.trendGranularity === 'hour' ? 45 : 0,
minRotation: this.trendGranularity === 'hour' ? 45 : 0
}
},
y: {
type: 'linear',
display: true,
@@ -1711,6 +1842,25 @@ const app = createApp({
mode: 'index',
intersect: false,
callbacks: {
title: (tooltipItems) => {
if (tooltipItems.length === 0) return '';
const index = tooltipItems[0].dataIndex;
const item = this.trendData[index];
if (this.trendGranularity === 'hour' && item.hour) {
// 小时粒度:显示完整的日期时间
const date = new Date(item.hour);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
// 天粒度:保持原有标签
return tooltipItems[0].label;
},
label: function(context) {
const label = context.dataset.label || '';
let value = context.parsed.y;
@@ -1739,6 +1889,178 @@ const app = createApp({
}
},
// 更新API Keys使用趋势图
updateApiKeysUsageTrendChart() {
// 检查Chart.js是否已加载
if (typeof Chart === 'undefined') {
console.warn('Chart.js not loaded yet, retrying...');
setTimeout(() => this.updateApiKeysUsageTrendChart(), 500);
return;
}
// 严格检查DOM元素是否有效
if (!this.isElementValid('apiKeysUsageTrendChart')) {
console.error('API keys usage trend chart canvas element not found or invalid');
return;
}
const ctx = document.getElementById('apiKeysUsageTrendChart');
// 安全销毁现有图表
if (this.apiKeysUsageTrendChart) {
try {
this.apiKeysUsageTrendChart.destroy();
} catch (error) {
console.warn('Error destroying API keys usage trend chart:', error);
}
this.apiKeysUsageTrendChart = null;
}
// 如果没有数据,不创建图表
if (!this.apiKeysTrendData.data || this.apiKeysTrendData.data.length === 0) {
console.warn('No API keys trend data available, skipping chart creation');
return;
}
// 准备数据
const labels = this.apiKeysTrendData.data.map(item => {
if (this.trendGranularity === 'hour') {
const date = new Date(item.hour);
return `${String(date.getHours()).padStart(2, '0')}:00`;
}
return item.date;
});
// 获取所有API Key的数据集
const datasets = [];
const colors = [
'rgb(102, 126, 234)',
'rgb(240, 147, 251)',
'rgb(59, 130, 246)',
'rgb(147, 51, 234)',
'rgb(34, 197, 94)',
'rgb(251, 146, 60)',
'rgb(239, 68, 68)',
'rgb(16, 185, 129)',
'rgb(245, 158, 11)',
'rgb(236, 72, 153)'
];
// 只显示前10个使用量最多的API Key
this.apiKeysTrendData.topApiKeys.forEach((apiKeyId, index) => {
const data = this.apiKeysTrendData.data.map(item => {
return item.apiKeys[apiKeyId] ? item.apiKeys[apiKeyId].tokens : 0;
});
// 获取API Key名称
const apiKeyName = this.apiKeysTrendData.data.find(item =>
item.apiKeys[apiKeyId]
)?.apiKeys[apiKeyId]?.name || `API Key ${apiKeyId}`;
datasets.push({
label: apiKeyName,
data: data,
borderColor: colors[index % colors.length],
backgroundColor: colors[index % colors.length] + '20',
tension: 0.3,
fill: false
});
});
try {
// 最后一次检查元素有效性
if (!this.isElementValid('apiKeysUsageTrendChart')) {
throw new Error('Canvas element is not valid for chart creation');
}
this.apiKeysUsageTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false, // 禁用动画防止异步渲染问题
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
type: 'category',
display: true,
title: {
display: true,
text: this.trendGranularity === 'hour' ? '时间' : '日期'
},
ticks: {
autoSkip: true,
maxRotation: this.trendGranularity === 'hour' ? 45 : 0,
minRotation: this.trendGranularity === 'hour' ? 45 : 0
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Token 数量'
},
ticks: {
callback: function(value) {
return value.toLocaleString();
}
}
}
},
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: (tooltipItems) => {
if (tooltipItems.length === 0) return '';
const index = tooltipItems[0].dataIndex;
const item = this.apiKeysTrendData.data[index];
if (this.trendGranularity === 'hour' && item.hour) {
const date = new Date(item.hour);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
return tooltipItems[0].label;
},
label: function(context) {
const label = context.dataset.label || '';
const value = context.parsed.y;
return label + ': ' + value.toLocaleString() + ' tokens';
}
}
}
}
}
});
} catch (error) {
console.error('Error creating API keys usage trend chart:', error);
this.apiKeysUsageTrendChart = null;
}
},
// 切换API Key模型统计展开状态
toggleApiKeyModelStats(keyId) {
if (!keyId) {
@@ -1933,20 +2255,51 @@ const app = createApp({
// 根据预设计算并设置自定义时间框的值
const option = this.dateFilter.presetOptions.find(opt => opt.value === preset);
if (option) {
const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - (option.days - 1));
const now = new Date();
let startDate, endDate;
if (this.trendGranularity === 'hour') {
// 小时粒度的预设处理
if (preset === 'last24h') {
endDate = new Date(now);
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
} else if (preset === 'yesterday') {
// 昨天的00:00到23:59
startDate = new Date(now);
startDate.setDate(startDate.getDate() - 1);
startDate.setHours(0, 0, 0, 0);
endDate = new Date(startDate);
endDate.setHours(23, 59, 59, 999);
} else if (preset === 'dayBefore') {
// 前天的00:00到23:59
startDate = new Date(now);
startDate.setDate(startDate.getDate() - 2);
startDate.setHours(0, 0, 0, 0);
endDate = new Date(startDate);
endDate.setHours(23, 59, 59, 999);
}
} else {
// 天粒度的预设处理(保持原有逻辑)
endDate = new Date(now);
startDate = new Date(now);
startDate.setDate(now.getDate() - (option.days - 1));
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
}
// 格式化为 Element Plus 需要的格式
const formatDate = (date) => {
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' 00:00:00';
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0');
};
this.dateFilter.customRange = [
formatDate(startDate),
formatDate(today)
formatDate(endDate)
];
}
@@ -2105,6 +2458,61 @@ const app = createApp({
// 重新加载数据
this.loadDashboardModelStats();
this.loadUsageTrend();
this.loadApiKeysUsageTrend();
},
// 设置趋势图粒度
setTrendGranularity(granularity) {
console.log('Setting trend granularity to:', granularity);
this.trendGranularity = granularity;
// 根据粒度更新预设选项
if (granularity === 'hour') {
this.dateFilter.presetOptions = [
{ value: 'last24h', label: '近24小时', hours: 24 },
{ value: 'yesterday', label: '昨天', hours: 24 },
{ value: 'dayBefore', label: '前天', hours: 24 }
];
// 检查当前自定义日期范围是否超过24小时
if (this.dateFilter.type === 'custom' && this.dateFilter.customRange && this.dateFilter.customRange.length === 2) {
const start = new Date(this.dateFilter.customRange[0]);
const end = new Date(this.dateFilter.customRange[1]);
const hoursDiff = (end - start) / (1000 * 60 * 60);
if (hoursDiff > 24) {
this.showToast('切换到小时粒度日期范围已调整为近24小时', 'info');
this.dateFilter.preset = 'last24h';
this.setDateFilterPreset('last24h');
}
} else if (['today', '7days', '30days'].includes(this.dateFilter.preset)) {
// 预设不兼容切换到近24小时
this.dateFilter.preset = 'last24h';
this.setDateFilterPreset('last24h');
}
} else {
// 恢复天粒度的选项
this.dateFilter.presetOptions = [
{ value: 'today', label: '今天', days: 1 },
{ value: '7days', label: '近7天', days: 7 },
{ value: '30days', label: '近30天', days: 30 }
];
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
if (['last24h', 'yesterday', 'dayBefore'].includes(this.dateFilter.preset)) {
this.dateFilter.preset = '7days';
this.setDateFilterPreset('7days');
} else if (this.dateFilter.type === 'custom') {
// 自定义日期范围在天粒度下通常不需要调整因为24小时肯定在31天内
// 只需要重新加载数据
this.refreshChartsData();
return;
}
}
// 重新加载数据
this.loadUsageTrend();
this.loadApiKeysUsageTrend();
},
// API Keys 日期筛选方法
@@ -2293,8 +2701,32 @@ const app = createApp({
// 检查日期范围限制
const start = new Date(value[0]);
const end = new Date(value[1]);
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
if (this.trendGranularity === 'hour') {
// 小时粒度限制24小时
const hoursDiff = (end - start) / (1000 * 60 * 60);
if (hoursDiff > 24) {
this.showToast('小时粒度下日期范围不能超过24小时', 'warning', '范围限制');
// 调整结束时间为开始时间后24小时
const newEnd = new Date(start.getTime() + 24 * 60 * 60 * 1000);
const formatDate = (date) => {
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0');
};
this.dateFilter.customRange = [
formatDate(start),
formatDate(newEnd)
];
this.dateFilter.customEnd = newEnd.toISOString().split('T')[0];
return;
}
} else {
// 天粒度限制31天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
if (daysDiff > 31) {
this.showToast('日期范围不能超过31天', 'warning', '范围限制');
// 重置为默认7天
@@ -2303,12 +2735,13 @@ const app = createApp({
this.dateFilter.preset = '7days';
return;
}
}
this.refreshChartsData();
} else if (value === null) {
// 清空时恢复默认
this.dateFilter.type = 'preset';
this.dateFilter.preset = '7days';
this.dateFilter.preset = this.trendGranularity === 'hour' ? 'last24h' : '7days';
this.dateFilter.customStart = '';
this.dateFilter.customEnd = '';
this.refreshChartsData();

View File

@@ -160,7 +160,12 @@
<div>
<p class="text-sm font-semibold text-gray-600 mb-1">Claude账户</p>
<p class="text-3xl font-bold text-gray-900">{{ dashboardData.totalAccounts }}</p>
<p class="text-xs text-gray-500 mt-1">活跃: {{ dashboardData.activeAccounts || 0 }}</p>
<p class="text-xs text-gray-500 mt-1">
活跃: {{ dashboardData.activeAccounts || 0 }}
<span v-if="dashboardData.rateLimitedAccounts > 0" class="text-yellow-600">
| 限流: {{ dashboardData.rateLimitedAccounts }}
</span>
</p>
</div>
<div class="stat-icon bg-gradient-to-br from-green-500 to-green-600">
<i class="fas fa-user-circle"></i>
@@ -292,8 +297,36 @@
</button>
</div>
<!-- 粒度切换按钮 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-1">
<button
@click="setTrendGranularity('day')"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
trendGranularity === 'day'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-calendar-day mr-1"></i>按天
</button>
<button
@click="setTrendGranularity('hour')"
:class="[
'px-3 py-1 rounded-md text-sm font-medium transition-colors',
trendGranularity === 'hour'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
]"
>
<i class="fas fa-clock mr-1"></i>按小时
</button>
</div>
<!-- Element Plus 日期范围选择器 -->
<div class="flex items-center gap-2">
<el-date-picker
:default-time="defaultTime"
v-model="dateFilter.customRange"
type="datetimerange"
range-separator="至"
@@ -307,6 +340,10 @@
style="width: 350px;"
class="custom-date-picker"
></el-date-picker>
<span v-if="trendGranularity === 'hour'" class="text-xs text-orange-600">
<i class="fas fa-info-circle"></i> 最多24小时
</span>
</div>
<button @click="refreshChartsData()" class="btn btn-primary px-4 py-2 flex items-center gap-2">
<i class="fas fa-sync-alt"></i>刷新
@@ -368,6 +405,24 @@
</div>
</div>
</div>
<!-- API Keys Token消耗趋势图 -->
<div class="mb-8">
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Keys Token 消耗趋势</h3>
<div class="mb-4 text-sm text-gray-600">
<span v-if="apiKeysTrendData.totalApiKeys > 10">
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key显示使用量前 10 个
</span>
<span v-else>
共 {{ apiKeysTrendData.totalApiKeys }} 个 API Key
</span>
</div>
<div style="height: 350px;">
<canvas id="apiKeysUsageTrendChart"></canvas>
</div>
</div>
</div>
</div>
<!-- API Keys 管理 -->
@@ -782,6 +837,11 @@
account.isActive ? 'bg-green-500' : 'bg-red-500']"></div>
{{ account.isActive ? '正常' : '异常' }}
</span>
<span v-if="account.rateLimitStatus && account.rateLimitStatus.isRateLimited"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-yellow-100 text-yellow-800">
<i class="fas fa-exclamation-triangle mr-1"></i>
限流中 ({{ account.rateLimitStatus.minutesRemaining }}分钟)
</span>
<span v-if="account.accountType === 'dedicated'"
class="text-xs text-gray-500">
绑定: {{ account.boundApiKeysCount || 0 }} 个API Key
@@ -1703,7 +1763,7 @@
<!-- 创建 API Key 模态框 -->
<div v-if="showCreateApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
@@ -1719,7 +1779,7 @@
</button>
</div>
<form @submit.prevent="createApiKey" class="space-y-6">
<form @submit.prevent="createApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
<input
@@ -1806,7 +1866,7 @@
<!-- 编辑 API Key 模态框 -->
<div v-if="showEditApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
@@ -1822,7 +1882,7 @@
</button>
</div>
<form @submit.prevent="updateApiKey" class="space-y-6">
<form @submit.prevent="updateApiKey" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">名称</label>
<input
@@ -1900,7 +1960,7 @@
<!-- 新创建的 API Key 展示弹窗 -->
<div v-if="showNewApiKeyModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-lg p-8 mx-auto">
<div class="modal-content w-full max-w-lg p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
@@ -1994,7 +2054,7 @@
<!-- 创建 Claude 账户模态框 -->
<div v-if="showCreateAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center">
@@ -2350,7 +2410,7 @@
<!-- 编辑 Claude 账户模态框 -->
<div v-if="showEditAccountModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto">
<div class="modal-content w-full max-w-2xl p-8 mx-auto max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
@@ -2544,7 +2604,7 @@
<!-- 修改账户信息模态框 -->
<div v-if="showChangePasswordModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto">
<div class="modal-content w-full max-w-md p-8 mx-auto max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
@@ -2560,7 +2620,7 @@
</button>
</div>
<form @submit.prevent="changePassword" class="space-y-6">
<form @submit.prevent="changePassword" class="space-y-6 modal-scroll-content custom-scrollbar flex-1">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">当前用户名</label>
<input

View File

@@ -378,6 +378,43 @@ body::before {
/* 自定义滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(102, 126, 234, 0.05);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
border-radius: 10px;
transition: background 0.3s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
}
.custom-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
}
/* 弹窗滚动内容样式 */
.modal-scroll-content {
max-height: calc(90vh - 160px);
overflow-y: auto;
padding-right: 8px;
}
@media (max-width: 768px) {
.glass, .glass-strong {
margin: 16px;
@@ -392,4 +429,8 @@ body::before {
font-size: 14px;
padding: 12px 8px;
}
.modal-scroll-content {
max-height: calc(85vh - 120px);
}
}