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

@@ -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,22 +2701,47 @@ 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 (daysDiff > 31) {
this.showToast('日期范围不能超过31天', 'warning', '范围限制');
// 重置为默认7天
this.dateFilter.customRange = null;
this.dateFilter.type = 'preset';
this.dateFilter.preset = '7days';
return;
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天
this.dateFilter.customRange = null;
this.dateFilter.type = 'preset';
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();