mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
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:
473
web/admin/app.js
473
web/admin/app.js
@@ -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();
|
||||
|
||||
@@ -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,21 +297,53 @@
|
||||
</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 日期范围选择器 -->
|
||||
<el-date-picker
|
||||
v-model="dateFilter.customRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="onCustomDateRangeChange"
|
||||
:disabled-date="disabledDate"
|
||||
size="default"
|
||||
style="width: 350px;"
|
||||
class="custom-date-picker"
|
||||
></el-date-picker>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-date-picker
|
||||
:default-time="defaultTime"
|
||||
v-model="dateFilter.customRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="onCustomDateRangeChange"
|
||||
:disabled-date="disabledDate"
|
||||
size="default"
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user