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

@@ -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,22 +530,140 @@ 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 = [];
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];
if (granularity === 'hour') {
// 小时粒度统计
let startTime, endTime;
// 汇总当天所有API Key的使用数据
const pattern = `usage:daily:*:${dateStr}`;
const keys = await client.keys(pattern);
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天的数据
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);
let dayInputTokens = 0;
let dayOutputTokens = 0;
@@ -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) {