1054 lines
34 KiB
JavaScript
1054 lines
34 KiB
JavaScript
// 全局变量
|
|
let currentPage = 'dashboard';
|
|
|
|
// 页面加载时初始化
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await checkAuth();
|
|
await loadStats();
|
|
await loadApiKeys();
|
|
await loadRecentActivity();
|
|
});
|
|
|
|
// 检查认证状态
|
|
async function checkAuth() {
|
|
try {
|
|
const response = await fetch('/admin/auth/check');
|
|
if (!response.ok) {
|
|
window.location.href = '/admin/login.html';
|
|
}
|
|
} catch (error) {
|
|
console.error('认证检查失败:', error);
|
|
window.location.href = '/admin/login.html';
|
|
}
|
|
}
|
|
|
|
// 加载统计数据
|
|
async function loadStats() {
|
|
try {
|
|
const response = await fetch('/admin/stats');
|
|
const data = await response.json();
|
|
|
|
document.getElementById('apiKeysCount').textContent = data.apiKeys || 0;
|
|
document.getElementById('tokensCount').textContent = data.tokens || 0;
|
|
document.getElementById('todayRequests').textContent = data.todayRequests || 0;
|
|
document.getElementById('successRate').textContent = (data.successRate || 100) + '%';
|
|
} catch (error) {
|
|
console.error('加载统计数据失败:', error);
|
|
}
|
|
}
|
|
|
|
// 加载最近活动记录
|
|
async function loadRecentActivity() {
|
|
try {
|
|
const response = await fetch('/admin/stats/recent-activity?limit=10');
|
|
const activities = await response.json();
|
|
|
|
const container = document.getElementById('recentActivity');
|
|
|
|
if (activities.length === 0) {
|
|
container.innerHTML = '<div class="text-center py-8 text-gray-500"><i class="fas fa-info-circle mr-2"></i>暂无活动记录</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = activities.map(activity => {
|
|
const timeAgo = getTimeAgo(activity.time);
|
|
return `
|
|
<div class="flex items-start space-x-3 py-3 border-b border-gray-100 last:border-0">
|
|
<div class="flex-shrink-0">
|
|
<div class="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
|
<i class="fas ${activity.icon} ${activity.color} text-sm"></i>
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900">${escapeHtml(activity.title)}</p>
|
|
<p class="text-xs text-gray-500 mt-0.5">${escapeHtml(activity.description)}</p>
|
|
</div>
|
|
<div class="flex-shrink-0">
|
|
<span class="text-xs text-gray-400">${timeAgo}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} catch (error) {
|
|
console.error('加载最近活动失败:', error);
|
|
}
|
|
}
|
|
|
|
// 计算时间差
|
|
function getTimeAgo(timestamp) {
|
|
if (!timestamp) return '未知';
|
|
|
|
const now = new Date();
|
|
const time = new Date(timestamp);
|
|
const diff = Math.floor((now - time) / 1000); // 秒
|
|
|
|
if (diff < 60) return '刚刚';
|
|
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
|
|
if (diff < 604800) return `${Math.floor(diff / 86400)} 天前`;
|
|
return time.toLocaleDateString('zh-CN');
|
|
}
|
|
|
|
// 切换页面
|
|
function switchPage(event, page) {
|
|
event.preventDefault();
|
|
currentPage = page;
|
|
|
|
// 更新导航样式
|
|
document.querySelectorAll('.nav-item').forEach(item => {
|
|
item.classList.remove('active', 'text-white');
|
|
item.classList.add('text-gray-700');
|
|
});
|
|
event.currentTarget.classList.add('active');
|
|
event.currentTarget.classList.remove('text-gray-700');
|
|
|
|
// 隐藏所有页面
|
|
document.getElementById('dashboardPage').classList.add('hidden');
|
|
document.getElementById('apikeysPage').classList.add('hidden');
|
|
document.getElementById('accountsPage').classList.add('hidden');
|
|
document.getElementById('analyticsPage').classList.add('hidden');
|
|
document.getElementById('settingsPage').classList.add('hidden');
|
|
|
|
// 更新页面标题
|
|
const titles = {
|
|
dashboard: { title: '仪表盘', desc: '系统概览和实时数据' },
|
|
apikeys: { title: 'API Keys', desc: 'API 密钥管理' },
|
|
accounts: { title: '账号管理', desc: 'Tokens 账户管理' },
|
|
analytics: { title: '数据分析', desc: 'API 请求统计和分析' },
|
|
settings: { title: '系统设置', desc: '系统配置和偏好设置' }
|
|
};
|
|
|
|
document.getElementById('pageTitle').textContent = titles[page].title;
|
|
document.getElementById('pageDesc').textContent = titles[page].desc;
|
|
|
|
// 显示对应页面
|
|
if (page === 'dashboard') {
|
|
document.getElementById('dashboardPage').classList.remove('hidden');
|
|
} else if (page === 'apikeys') {
|
|
document.getElementById('apikeysPage').classList.remove('hidden');
|
|
loadApiKeys();
|
|
} else if (page === 'accounts') {
|
|
document.getElementById('accountsPage').classList.remove('hidden');
|
|
loadTokens();
|
|
loadLoadBalanceStrategy();
|
|
} else if (page === 'analytics') {
|
|
document.getElementById('analyticsPage').classList.remove('hidden');
|
|
loadAnalytics();
|
|
} else if (page === 'settings') {
|
|
document.getElementById('settingsPage').classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// 切换账号管理标签
|
|
// 已移除,不再需要
|
|
|
|
// ==================== API Keys 管理 ====================
|
|
|
|
async function loadApiKeys() {
|
|
try {
|
|
const response = await fetch('/admin/api-keys');
|
|
const data = await response.json();
|
|
|
|
const tbody = document.getElementById('apiKeysTable');
|
|
|
|
if (data.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-8 text-gray-500">暂无 API Key</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.map(key => `
|
|
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
|
<td class="py-4 px-4 text-sm text-gray-900">${escapeHtml(key.name || '-')}</td>
|
|
<td class="py-4 px-4">
|
|
<code class="text-xs bg-gray-100 px-2 py-1 rounded">${escapeHtml(key.key.substring(0, 20))}...</code>
|
|
<button onclick="copyToClipboard('${escapeHtml(key.key)}')" class="ml-2 text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-copy"></i>
|
|
</button>
|
|
</td>
|
|
<td class="py-4 px-4 text-sm text-gray-600">${key.usage_count || 0}</td>
|
|
<td class="py-4 px-4 text-sm text-gray-600">${key.last_used_at ? new Date(key.last_used_at).toLocaleString('zh-CN') : '-'}</td>
|
|
<td class="py-4 px-4">
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${key.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
|
|
${key.is_active ? '启用' : '禁用'}
|
|
</span>
|
|
</td>
|
|
<td class="py-4 px-4">
|
|
<button onclick="toggleApiKey(${key.id}, ${key.is_active})" class="text-sm text-gray-600 hover:text-gray-900 mr-3">
|
|
${key.is_active ? '禁用' : '启用'}
|
|
</button>
|
|
<button onclick="deleteApiKey(${key.id})" class="text-sm text-red-600 hover:text-red-800">
|
|
删除
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (error) {
|
|
console.error('加载 API Keys 失败:', error);
|
|
}
|
|
}
|
|
|
|
function showCreateApiKeyModal() {
|
|
document.getElementById('createApiKeyModal').classList.remove('hidden');
|
|
}
|
|
|
|
async function handleCreateApiKey(event) {
|
|
event.preventDefault();
|
|
|
|
const name = document.getElementById('apiKeyName').value;
|
|
|
|
try {
|
|
const response = await fetch('/admin/api-keys', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
document.getElementById('createApiKeyModal').classList.add('hidden');
|
|
document.getElementById('apiKeyName').value = '';
|
|
alert('API Key 创建成功!\n\n' + data.key + '\n\n请妥善保存,此 Key 不会再次显示!');
|
|
await loadApiKeys();
|
|
await loadStats();
|
|
} else {
|
|
alert('创建失败: ' + (data.error || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
alert('创建失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function toggleApiKey(id, currentStatus) {
|
|
try {
|
|
const response = await fetch(`/admin/api-keys/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_active: !currentStatus })
|
|
});
|
|
|
|
if (response.ok) {
|
|
await loadApiKeys();
|
|
await loadStats();
|
|
}
|
|
} catch (error) {
|
|
alert('操作失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteApiKey(id) {
|
|
if (!confirm('确定要删除此 API Key 吗?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/admin/api-keys/${id}`, { method: 'DELETE' });
|
|
if (response.ok) {
|
|
await loadApiKeys();
|
|
await loadStats();
|
|
}
|
|
} catch (error) {
|
|
alert('删除失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ==================== Tokens 管理 ====================
|
|
|
|
let currentTokenPage = 1;
|
|
let tokenPageSize = 20;
|
|
let totalTokens = 0;
|
|
let selectedTokens = new Set();
|
|
|
|
async function loadTokens(page = 1) {
|
|
try {
|
|
currentTokenPage = page;
|
|
selectedTokens.clear();
|
|
updateBatchDeleteButton();
|
|
|
|
const response = await fetch(`/admin/tokens?page=${page}&limit=${tokenPageSize}`);
|
|
const result = await response.json();
|
|
|
|
const data = result.data || [];
|
|
const pagination = result.pagination || {};
|
|
totalTokens = pagination.total || 0;
|
|
|
|
// 更新账号总数显示
|
|
const totalCountEl = document.getElementById('totalTokensCount');
|
|
if (totalCountEl) {
|
|
totalCountEl.textContent = totalTokens;
|
|
}
|
|
|
|
const tbody = document.getElementById('tokensTable');
|
|
|
|
if (data.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="9" class="text-center py-8 text-gray-500">暂无 Token</td></tr>';
|
|
updateTokenPagination(0, 0);
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.map(token => {
|
|
// 计算额度百分比
|
|
const quotaTotal = token.quota_total || 0;
|
|
const quotaUsed = token.quota_used || 0;
|
|
const quotaRemaining = token.quota_remaining || 0;
|
|
const quotaPercent = quotaTotal > 0 ? Math.round((quotaUsed / quotaTotal) * 100) : 0;
|
|
|
|
// 额度显示颜色
|
|
let quotaColor = 'text-green-600';
|
|
if (quotaPercent > 80) quotaColor = 'text-red-600';
|
|
else if (quotaPercent > 50) quotaColor = 'text-yellow-600';
|
|
|
|
// 额度显示文本
|
|
let quotaText = '-';
|
|
if (quotaTotal > 0) {
|
|
quotaText = `<div class="text-xs ${quotaColor}">
|
|
<div class="font-medium">${quotaRemaining.toLocaleString()} / ${quotaTotal.toLocaleString()}</div>
|
|
<div class="text-gray-500">${quotaPercent}% 已用</div>
|
|
</div>`;
|
|
}
|
|
|
|
return `
|
|
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
|
<td class="py-4 px-4">
|
|
<input type="checkbox" class="token-checkbox rounded border-gray-300 text-blue-600 focus:ring-blue-500" value="${token.id}" onchange="toggleTokenSelection(${token.id})" />
|
|
</td>
|
|
<td class="py-4 px-4 text-sm text-gray-900">${escapeHtml(token.name || '-')}</td>
|
|
<td class="py-4 px-4">${quotaText}</td>
|
|
<td class="py-4 px-4 text-sm font-medium text-gray-900">${token.total_requests || 0}</td>
|
|
<td class="py-4 px-4 text-sm text-green-600">${token.success_requests || 0}</td>
|
|
<td class="py-4 px-4 text-sm text-red-600">${token.failed_requests || 0}</td>
|
|
<td class="py-4 px-4 text-sm text-gray-600">${token.expired_at ? new Date(token.expired_at).toLocaleString('zh-CN') : '-'}</td>
|
|
<td class="py-4 px-4">
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${token.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
|
|
${token.is_active ? '启用' : '禁用'}
|
|
</span>
|
|
</td>
|
|
<td class="py-4 px-4">
|
|
<button onclick="refreshTokenQuota(${token.id})" class="text-sm text-blue-600 hover:text-blue-800 mr-2" title="刷新额度">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
<button onclick="toggleToken(${token.id}, ${token.is_active})" class="text-sm text-gray-600 hover:text-gray-900 mr-2">
|
|
${token.is_active ? '禁用' : '启用'}
|
|
</button>
|
|
<button onclick="deleteToken(${token.id})" class="text-sm text-red-600 hover:text-red-800">
|
|
删除
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
updateTokenPagination(pagination.page, pagination.totalPages);
|
|
} catch (error) {
|
|
console.error('加载 Tokens 失败:', error);
|
|
}
|
|
}
|
|
|
|
function updateTokenPagination(currentPage, totalPages) {
|
|
const paginationEl = document.getElementById('tokenPagination');
|
|
if (!paginationEl) return;
|
|
|
|
if (totalPages <= 1) {
|
|
paginationEl.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
let html = '<div class="flex items-center justify-between mt-4">';
|
|
html += `<div class="text-sm text-gray-600">共 ${totalTokens} 个账号,第 ${currentPage}/${totalPages} 页</div>`;
|
|
html += '<div class="flex space-x-2">';
|
|
|
|
// 上一页
|
|
if (currentPage > 1) {
|
|
html += `<button onclick="loadTokens(${currentPage - 1})" class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">上一页</button>`;
|
|
} else {
|
|
html += `<button disabled class="px-3 py-1 border border-gray-200 rounded text-gray-400 cursor-not-allowed">上一页</button>`;
|
|
}
|
|
|
|
// 页码
|
|
const maxPages = 5;
|
|
let startPage = Math.max(1, currentPage - Math.floor(maxPages / 2));
|
|
let endPage = Math.min(totalPages, startPage + maxPages - 1);
|
|
|
|
if (endPage - startPage < maxPages - 1) {
|
|
startPage = Math.max(1, endPage - maxPages + 1);
|
|
}
|
|
|
|
if (startPage > 1) {
|
|
html += `<button onclick="loadTokens(1)" class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">1</button>`;
|
|
if (startPage > 2) {
|
|
html += `<span class="px-2 py-1">...</span>`;
|
|
}
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
if (i === currentPage) {
|
|
html += `<button class="px-3 py-1 bg-blue-500 text-white rounded">${i}</button>`;
|
|
} else {
|
|
html += `<button onclick="loadTokens(${i})" class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">${i}</button>`;
|
|
}
|
|
}
|
|
|
|
if (endPage < totalPages) {
|
|
if (endPage < totalPages - 1) {
|
|
html += `<span class="px-2 py-1">...</span>`;
|
|
}
|
|
html += `<button onclick="loadTokens(${totalPages})" class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">${totalPages}</button>`;
|
|
}
|
|
|
|
// 下一页
|
|
if (currentPage < totalPages) {
|
|
html += `<button onclick="loadTokens(${currentPage + 1})" class="px-3 py-1 border border-gray-300 rounded hover:bg-gray-50">下一页</button>`;
|
|
} else {
|
|
html += `<button disabled class="px-3 py-1 border border-gray-200 rounded text-gray-400 cursor-not-allowed">下一页</button>`;
|
|
}
|
|
|
|
html += '</div></div>';
|
|
paginationEl.innerHTML = html;
|
|
}
|
|
|
|
function showCreateTokenModal() {
|
|
document.getElementById('createTokenModal').classList.remove('hidden');
|
|
}
|
|
|
|
function showImportTokenModal() {
|
|
document.getElementById('importTokenModal').classList.remove('hidden');
|
|
|
|
// 监听文件选择
|
|
document.getElementById('tokenFileInput').addEventListener('change', handleFileSelect);
|
|
}
|
|
|
|
function closeImportModal() {
|
|
document.getElementById('importTokenModal').classList.add('hidden');
|
|
document.getElementById('tokenFileInput').value = '';
|
|
document.getElementById('tokenJsonContent').value = '';
|
|
document.getElementById('importPreview').classList.add('hidden');
|
|
}
|
|
|
|
function handleFileSelect(event) {
|
|
const files = event.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
// 如果只有一个文件,直接读取
|
|
if (files.length === 1) {
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
document.getElementById('tokenJsonContent').value = e.target.result;
|
|
};
|
|
reader.onerror = function(e) {
|
|
alert('文件读取失败: ' + e.target.error);
|
|
};
|
|
reader.readAsText(files[0]);
|
|
return;
|
|
}
|
|
|
|
// 多个文件,合并成数组
|
|
let allTokens = [];
|
|
let filesRead = 0;
|
|
const totalFiles = files.length;
|
|
|
|
console.log(`开始读取 ${totalFiles} 个文件...`);
|
|
|
|
Array.from(files).forEach((file, index) => {
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
try {
|
|
console.log(`读取文件 ${index + 1}/${totalFiles}: ${file.name}`);
|
|
const data = JSON.parse(e.target.result);
|
|
// 如果是数组,展开;如果是对象,作为单个元素
|
|
if (Array.isArray(data)) {
|
|
allTokens = allTokens.concat(data);
|
|
console.log(`文件 ${file.name} 包含 ${data.length} 个 token`);
|
|
} else {
|
|
allTokens.push(data);
|
|
console.log(`文件 ${file.name} 包含 1 个 token`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`文件 ${file.name} 解析失败:`, error);
|
|
alert(`文件 ${file.name} 解析失败: ${error.message}`);
|
|
}
|
|
|
|
filesRead++;
|
|
// 所有文件都读取完成后,更新文本框
|
|
if (filesRead === totalFiles) {
|
|
console.log(`所有文件读取完成,共 ${allTokens.length} 个 token`);
|
|
document.getElementById('tokenJsonContent').value = JSON.stringify(allTokens, null, 2);
|
|
}
|
|
};
|
|
reader.onerror = function(e) {
|
|
console.error(`文件 ${file.name} 读取失败:`, e.target.error);
|
|
alert(`文件 ${file.name} 读取失败`);
|
|
filesRead++;
|
|
if (filesRead === totalFiles && allTokens.length > 0) {
|
|
document.getElementById('tokenJsonContent').value = JSON.stringify(allTokens, null, 2);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
}
|
|
|
|
let importData = null;
|
|
|
|
function previewImport() {
|
|
const jsonContent = document.getElementById('tokenJsonContent').value.trim();
|
|
|
|
if (!jsonContent) {
|
|
alert('请先选择文件或粘贴 JSON 内容');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
importData = JSON.parse(jsonContent);
|
|
|
|
if (!Array.isArray(importData)) {
|
|
importData = [importData];
|
|
}
|
|
|
|
// 验证数据格式
|
|
const validTokens = importData.filter(token => {
|
|
return token.access_token && token.refresh_token;
|
|
});
|
|
|
|
if (validTokens.length === 0) {
|
|
alert('JSON 格式错误:未找到有效的 token 数据\n\n每个 token 必须包含 access_token 和 refresh_token 字段');
|
|
return;
|
|
}
|
|
|
|
// 显示预览
|
|
document.getElementById('importCount').textContent = validTokens.length;
|
|
const listEl = document.getElementById('importList');
|
|
listEl.innerHTML = validTokens.map((token, index) => `
|
|
<li class="flex items-center space-x-2">
|
|
<i class="fas fa-check-circle text-green-500"></i>
|
|
<span>${index + 1}. ${escapeHtml(token.name || token.email || token.account_id || 'Token ' + (index + 1))}</span>
|
|
</li>
|
|
`).join('');
|
|
|
|
document.getElementById('importPreview').classList.remove('hidden');
|
|
importData = validTokens;
|
|
|
|
} catch (error) {
|
|
alert('JSON 解析失败:' + error.message);
|
|
}
|
|
}
|
|
|
|
async function handleImportTokens() {
|
|
if (!importData || importData.length === 0) {
|
|
alert('请先预览导入数据');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`确定要导入 ${importData.length} 个账户吗?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/admin/tokens/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tokens: importData })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
alert(`导入成功!\n成功:${data.success || 0} 个\n失败:${data.failed || 0} 个`);
|
|
closeImportModal();
|
|
await loadTokens();
|
|
await loadStats();
|
|
} else {
|
|
alert('导入失败: ' + (data.error || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
alert('导入失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function handleCreateToken(event) {
|
|
event.preventDefault();
|
|
|
|
const name = document.getElementById('tokenName').value;
|
|
const access_token = document.getElementById('accessToken').value;
|
|
const refresh_token = document.getElementById('refreshToken').value;
|
|
|
|
try {
|
|
const response = await fetch('/admin/tokens', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, access_token, refresh_token })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
document.getElementById('createTokenModal').classList.add('hidden');
|
|
document.getElementById('tokenName').value = '';
|
|
document.getElementById('accessToken').value = '';
|
|
document.getElementById('refreshToken').value = '';
|
|
alert('Token 添加成功!');
|
|
await loadTokens();
|
|
await loadStats();
|
|
} else {
|
|
alert('添加失败: ' + (data.error || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
alert('添加失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function toggleToken(id, currentStatus) {
|
|
try {
|
|
const response = await fetch(`/admin/tokens/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_active: !currentStatus })
|
|
});
|
|
|
|
if (response.ok) {
|
|
await loadTokens();
|
|
await loadStats();
|
|
}
|
|
} catch (error) {
|
|
alert('操作失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteToken(id) {
|
|
if (!confirm('确定要删除此 Token 吗?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/admin/tokens/${id}`, { method: 'DELETE' });
|
|
if (response.ok) {
|
|
await loadTokens(currentTokenPage);
|
|
await loadStats();
|
|
}
|
|
} catch (error) {
|
|
alert('删除失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function refreshTokenQuota(id) {
|
|
try {
|
|
const response = await fetch(`/admin/tokens/${id}/quota`, { method: 'POST' });
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
await loadTokens(currentTokenPage);
|
|
if (data.quota) {
|
|
alert(`额度已更新\n总额度: ${data.quota.total.toLocaleString()}\n已使用: ${data.quota.used.toLocaleString()}\n剩余: ${data.quota.remaining.toLocaleString()}`);
|
|
}
|
|
} else {
|
|
alert('刷新额度失败: ' + (data.error || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
alert('刷新额度失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function refreshAllQuotas() {
|
|
if (!confirm('确定要刷新所有账号的额度吗?这可能需要一些时间。')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/admin/tokens/quota/refresh-all', { method: 'POST' });
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
await loadTokens(currentTokenPage);
|
|
alert(`批量刷新完成\n成功: ${data.success || 0} 个\n失败: ${data.failed || 0} 个`);
|
|
} else {
|
|
alert('批量刷新失败: ' + (data.error || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
alert('批量刷新失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ==================== 批量删除功能 ====================
|
|
|
|
function toggleTokenSelection(id) {
|
|
if (selectedTokens.has(id)) {
|
|
selectedTokens.delete(id);
|
|
} else {
|
|
selectedTokens.add(id);
|
|
}
|
|
updateBatchDeleteButton();
|
|
updateSelectAllCheckbox();
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const checkbox = document.getElementById('selectAllTokens');
|
|
const checkboxes = document.querySelectorAll('.token-checkbox');
|
|
|
|
if (checkbox.checked) {
|
|
checkboxes.forEach(cb => {
|
|
const id = parseInt(cb.value);
|
|
selectedTokens.add(id);
|
|
cb.checked = true;
|
|
});
|
|
} else {
|
|
selectedTokens.clear();
|
|
checkboxes.forEach(cb => {
|
|
cb.checked = false;
|
|
});
|
|
}
|
|
|
|
updateBatchDeleteButton();
|
|
}
|
|
|
|
function updateSelectAllCheckbox() {
|
|
const checkbox = document.getElementById('selectAllTokens');
|
|
const checkboxes = document.querySelectorAll('.token-checkbox');
|
|
|
|
if (checkboxes.length === 0) {
|
|
checkbox.checked = false;
|
|
return;
|
|
}
|
|
|
|
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
|
checkbox.checked = allChecked;
|
|
}
|
|
|
|
function updateBatchDeleteButton() {
|
|
const btn = document.getElementById('batchDeleteBtn');
|
|
const countSpan = document.getElementById('selectedCount');
|
|
|
|
if (selectedTokens.size > 0) {
|
|
btn.classList.remove('hidden');
|
|
countSpan.textContent = selectedTokens.size;
|
|
} else {
|
|
btn.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
async function batchDeleteTokens() {
|
|
if (selectedTokens.size === 0) {
|
|
alert('请先选择要删除的账号');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`确定要删除选中的 ${selectedTokens.size} 个账号吗?此操作不可恢复!`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const ids = Array.from(selectedTokens);
|
|
const response = await fetch('/admin/tokens/batch-delete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ids })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
alert(`批量删除完成\n成功: ${data.success || 0} 个\n失败: ${data.failed || 0} 个`);
|
|
selectedTokens.clear();
|
|
await loadTokens(currentTokenPage);
|
|
await loadStats();
|
|
} else {
|
|
alert('批量删除失败: ' + (data.error || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
alert('批量删除失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ==================== 日志管理 ====================
|
|
|
|
async function loadAnalytics() {
|
|
// 加载统计数据
|
|
await loadAnalyticsStats();
|
|
// 加载图表
|
|
await loadCharts();
|
|
// 加载模型统计
|
|
await loadModelStats();
|
|
// 加载日志
|
|
await loadLogs();
|
|
}
|
|
|
|
let currentTimeRange = '24h';
|
|
|
|
function changeTimeRange(range) {
|
|
currentTimeRange = range;
|
|
|
|
// 更新按钮样式
|
|
document.querySelectorAll('.time-range-btn').forEach(btn => {
|
|
btn.classList.remove('bg-blue-500', 'text-white');
|
|
btn.classList.add('text-gray-700', 'hover:bg-gray-100');
|
|
});
|
|
event.target.classList.add('bg-blue-500', 'text-white');
|
|
event.target.classList.remove('text-gray-700', 'hover:bg-gray-100');
|
|
|
|
// 重新加载数据
|
|
loadAnalytics();
|
|
}
|
|
|
|
async function loadAnalyticsStats() {
|
|
try {
|
|
const response = await fetch(`/admin/stats/analytics?range=${currentTimeRange}`);
|
|
const data = await response.json();
|
|
|
|
document.getElementById('totalRequests').textContent = data.totalRequests || 0;
|
|
document.getElementById('successRequests').textContent = data.successRequests || 0;
|
|
document.getElementById('failedRequests').textContent = data.failedRequests || 0;
|
|
document.getElementById('avgResponseTime').textContent = (data.avgResponseTime || 0) + 'ms';
|
|
} catch (error) {
|
|
console.error('加载统计数据失败:', error);
|
|
}
|
|
}
|
|
|
|
let requestTrendChart = null;
|
|
let modelDistributionChart = null;
|
|
|
|
async function loadCharts() {
|
|
try {
|
|
const response = await fetch(`/admin/stats/charts?range=${currentTimeRange}`);
|
|
const data = await response.json();
|
|
|
|
// 请求量趋势图
|
|
const trendCtx = document.getElementById('requestTrendChart').getContext('2d');
|
|
if (requestTrendChart) {
|
|
requestTrendChart.destroy();
|
|
}
|
|
requestTrendChart = new Chart(trendCtx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.trendLabels || [],
|
|
datasets: [{
|
|
label: '请求数',
|
|
data: data.trendData || [],
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
tension: 0.4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 模型使用分布饼图
|
|
const distCtx = document.getElementById('modelDistributionChart').getContext('2d');
|
|
if (modelDistributionChart) {
|
|
modelDistributionChart.destroy();
|
|
}
|
|
modelDistributionChart = new Chart(distCtx, {
|
|
type: 'pie',
|
|
data: {
|
|
labels: data.modelLabels || [],
|
|
datasets: [{
|
|
data: data.modelData || [],
|
|
backgroundColor: [
|
|
'#3b82f6',
|
|
'#10b981',
|
|
'#f59e0b',
|
|
'#ef4444',
|
|
'#8b5cf6',
|
|
'#ec4899'
|
|
]
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'right'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('加载图表失败:', error);
|
|
}
|
|
}
|
|
|
|
async function loadModelStats() {
|
|
try {
|
|
const response = await fetch(`/admin/stats/accounts?range=${currentTimeRange}`);
|
|
const data = await response.json();
|
|
|
|
const tbody = document.getElementById('accountStatsTable');
|
|
|
|
if (data.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-8 text-gray-500">暂无数据</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.map(account => `
|
|
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
|
<td class="py-4 px-4 text-sm font-medium text-gray-900">${escapeHtml(account.name)}</td>
|
|
<td class="py-4 px-4 text-sm text-gray-600">${account.requests}</td>
|
|
<td class="py-4 px-4">
|
|
<span class="text-sm font-medium ${account.successRate >= 95 ? 'text-green-600' : account.successRate >= 80 ? 'text-yellow-600' : 'text-red-600'}">
|
|
${account.successRate}%
|
|
</span>
|
|
</td>
|
|
<td class="py-4 px-4 text-sm text-gray-600">${account.avgResponseTime}ms</td>
|
|
<td class="py-4 px-4 text-sm text-gray-500">${account.lastUsed ? new Date(account.lastUsed).toLocaleString('zh-CN') : '-'}</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (error) {
|
|
console.error('加载账号统计失败:', error);
|
|
}
|
|
}
|
|
|
|
async function loadLogs() {
|
|
try {
|
|
const response = await fetch(`/admin/stats/logs?limit=50&range=${currentTimeRange}`);
|
|
const data = await response.json();
|
|
|
|
const tbody = document.getElementById('logsTable');
|
|
|
|
if (data.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-8 text-gray-500">暂无日志</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.map(log => `
|
|
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
|
<td class="py-3 px-4 text-xs text-gray-600">${new Date(log.created_at).toLocaleString('zh-CN')}</td>
|
|
<td class="py-3 px-4 text-xs text-gray-600">${log.api_key_name || log.api_key_id || '-'}</td>
|
|
<td class="py-3 px-4 text-xs text-gray-600">${escapeHtml(log.model || '-')}</td>
|
|
<td class="py-3 px-4">
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${log.status_code >= 200 && log.status_code < 300 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
|
|
${log.status_code}
|
|
</span>
|
|
</td>
|
|
<td class="py-3 px-4 text-xs text-gray-600">${log.response_time || '-'}ms</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (error) {
|
|
console.error('加载日志失败:', error);
|
|
}
|
|
}
|
|
|
|
// ==================== 工具函数 ====================
|
|
|
|
async function handleLogout() {
|
|
if (!confirm('确定要退出登录吗?')) return;
|
|
|
|
try {
|
|
await fetch('/admin/auth/logout', { method: 'POST' });
|
|
window.location.href = '/admin/login.html';
|
|
} catch (error) {
|
|
window.location.href = '/admin/login.html';
|
|
}
|
|
}
|
|
|
|
function copyToClipboard(text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
alert('已复制到剪贴板!');
|
|
}).catch(() => {
|
|
alert('复制失败,请手动复制');
|
|
});
|
|
}
|
|
|
|
// ==================== 负载均衡策略管理 ====================
|
|
|
|
async function loadLoadBalanceStrategy() {
|
|
try {
|
|
const response = await fetch('/admin/settings/load-balance-strategy');
|
|
const data = await response.json();
|
|
|
|
const select = document.getElementById('loadBalanceStrategy');
|
|
if (select && data.strategy) {
|
|
select.value = data.strategy;
|
|
}
|
|
} catch (error) {
|
|
console.error('加载负载均衡策略失败:', error);
|
|
}
|
|
}
|
|
|
|
async function changeLoadBalanceStrategy() {
|
|
const select = document.getElementById('loadBalanceStrategy');
|
|
const strategy = select.value;
|
|
|
|
try {
|
|
const response = await fetch('/admin/settings/load-balance-strategy', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ strategy })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
alert('负载均衡策略已更新为:' + (strategy === 'round-robin' ? '轮询' : strategy === 'random' ? '随机' : '最少使用'));
|
|
} else {
|
|
alert('更新失败: ' + (data.error || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
alert('更新失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ==================== 修改密码 ====================
|
|
|
|
function showChangePasswordModal() {
|
|
document.getElementById('changePasswordModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeChangePasswordModal() {
|
|
document.getElementById('changePasswordModal').classList.add('hidden');
|
|
document.getElementById('currentPassword').value = '';
|
|
document.getElementById('newPassword').value = '';
|
|
document.getElementById('confirmPassword').value = '';
|
|
}
|
|
|
|
async function handleChangePassword(event) {
|
|
event.preventDefault();
|
|
|
|
const currentPassword = document.getElementById('currentPassword').value;
|
|
const newPassword = document.getElementById('newPassword').value;
|
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
alert('两次输入的新密码不一致');
|
|
return;
|
|
}
|
|
|
|
if (newPassword.length < 6) {
|
|
alert('密码长度至少 6 位');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/admin/auth/change-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ oldPassword: currentPassword, newPassword })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
alert('密码修改成功,请重新登录');
|
|
closeChangePasswordModal();
|
|
window.location.href = '/admin/login.html';
|
|
} else {
|
|
alert('修改失败: ' + (data.error || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
alert('修改失败: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// ==================== 工具函数 ====================
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|