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

@@ -228,22 +228,35 @@ class ClaudeAccountService {
try {
const accounts = await redis.getAllClaudeAccounts();
// 处理返回数据,移除敏感信息
return accounts.map(account => ({
id: account.id,
name: account.name,
description: account.description,
email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '',
isActive: account.isActive === 'true',
proxy: account.proxy ? JSON.parse(account.proxy) : null,
status: account.status,
errorMessage: account.errorMessage,
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
expiresAt: account.expiresAt
// 处理返回数据,移除敏感信息并添加限流状态
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,
email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '',
isActive: account.isActive === 'true',
proxy: account.proxy ? JSON.parse(account.proxy) : null,
status: account.status,
errorMessage: account.errorMessage,
accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享
createdAt: account.createdAt,
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
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) {
logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`);
return mappedAccountId;
// 如果映射的账户被限流了,删除映射并重新选择
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 aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
return bLastRefresh - aLastRefresh;
});
const selectedAccountId = sortedAccounts[0].id;
// 将账户分为限流和非限流两组
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;
});
}
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();
});