paper-burner/admin/admin-enhanced.js

1043 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Paper Burner X - 管理员面板增强功能
// 包含:配额管理、详细统计、趋势图表、活动日志
// ==================== 全局网络与提示(超时/拦截/离线提示) ====================
// 统一超时
axios.defaults.timeout = 15000; // 15s
// 简易 Toast 实现
function ensureToastContainer() {
let c = document.getElementById('toast-container');
if (!c) {
c = document.createElement('div');
c.id = 'toast-container';
c.style.position = 'fixed';
c.style.top = '12px';
c.style.right = '12px';
c.style.zIndex = '9999';
c.style.display = 'flex';
c.style.flexDirection = 'column';
c.style.gap = '8px';
document.body.appendChild(c);
}
return c;
}
function showToast(message, type = 'info', duration = 3000) {
const c = ensureToastContainer();
const el = document.createElement('div');
const base = 'px-3 py-2 rounded shadow text-sm text-white';
const color = type === 'error' ? 'bg-red-600' : type === 'warn' ? 'bg-yellow-600' : 'bg-gray-800';
el.className = `${base} ${color}`;
el.textContent = message;
c.appendChild(el);
setTimeout(() => { try { c.removeChild(el); } catch {} }, duration);
}
// 离线横幅
function ensureOfflineBanner() {
let b = document.getElementById('offline-banner');
if (!b) {
b = document.createElement('div');
b.id = 'offline-banner';
b.className = 'w-full text-center text-sm text-white bg-red-600 py-2 hidden';
b.textContent = '当前处于离线状态,部分功能不可用;请检查网络连接。';
document.body.prepend(b);
}
return b;
}
function updateOfflineBanner() {
const b = ensureOfflineBanner();
if (navigator.onLine) {
b.classList.add('hidden');
} else {
b.classList.remove('hidden');
}
}
window.addEventListener('online', () => {
updateOfflineBanner();
showToast('网络已恢复', 'info', 1500);
});
window.addEventListener('offline', () => {
updateOfflineBanner();
showToast('网络连接断开', 'warn');
});
// Axios 响应错误统一处理 + GET 一次自动重试
axios.interceptors.response.use(
(res) => res,
async (error) => {
const config = error?.config || {};
const isGet = (config.method || 'get').toLowerCase() === 'get';
const transient = !error.response; // 网络/超时等
const code = error.code || '';
// 自动重试:仅 GET且网络错误/超时,最多 1 次
if (isGet && transient && !config.__retried) {
config.__retried = true;
await new Promise(r => setTimeout(r, 800));
try { return await axios(config); } catch (e) { /* fallthrough */ }
}
// 统一提示
if (transient || code === 'ECONNABORTED') {
showToast('网络连接中断或超时,请稍后重试', 'warn');
} else if (error.response) {
const msg = error.response?.data?.error || `请求失败 (${error.response.status})`;
showToast(msg, 'error');
} else {
showToast('请求失败,请检查网络或稍后再试', 'error');
}
return Promise.reject(error);
}
);
// ================ URL Hash 持久化日期筛选 ================
function persistRangeToHash() {
try {
const s = document.getElementById('statsStartDate')?.value || '';
const e = document.getElementById('statsEndDate')?.value || '';
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
if (s) params.set('startDate', s); else params.delete('startDate');
if (e) params.set('endDate', e); else params.delete('endDate');
const next = params.toString();
location.hash = next ? `#${next}` : '';
} catch {}
}
function restoreRangeFromHash() {
try {
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
const s = params.get('startDate') || '';
const e = params.get('endDate') || '';
const sd = document.getElementById('statsStartDate');
const ed = document.getElementById('statsEndDate');
if (sd) sd.value = s;
if (ed) ed.value = e;
} catch {}
}
function persistTabToHash(tab) {
try {
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
if (tab) params.set('tab', tab); else params.delete('tab');
const next = params.toString();
location.hash = next ? `#${next}` : '';
} catch {}
}
function restoreTabFromHash() {
try {
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
const tab = params.get('tab');
if (tab) {
// 若 hash 指向的 tab 存在,则切换;否则保持默认
const known = ['overview','users','quotas','activity','models','system'];
if (known.includes(tab)) {
// 直接调用增强后的切换函数(会触发加载与持久化)
switchTab(tab);
return true;
}
}
} catch {}
return false;
}
// ==================== 详细统计 ====================
function getStatsRangeParams() {
const s = document.getElementById('statsStartDate')?.value || '';
const e = document.getElementById('statsEndDate')?.value || '';
const params = new URLSearchParams();
if (s) params.set('startDate', s);
if (e) params.set('endDate', e);
const qs = params.toString();
return qs ? `?${qs}` : '';
}
function updateStatsRangeHint() {
const s = document.getElementById('statsStartDate')?.value || '';
const e = document.getElementById('statsEndDate')?.value || '';
const el = document.getElementById('statsRangeHint');
if (!el) return;
if (!s && !e) {
el.textContent = '当前筛选:全部';
return;
}
if (s && e) {
el.textContent = `当前筛选:${s} - ${e}`;
} else if (s) {
el.textContent = `当前筛选:自 ${s}`;
} else {
el.textContent = `当前筛选:截至 ${e}`;
}
}
async function loadDetailedStats() {
try {
const range = getStatsRangeParams();
const response = await axios.get(`${API_BASE}/admin/stats/detailed${range}`, {
headers: { Authorization: `Bearer ${authToken}` }
});
const stats = response.data;
// 更新额外统计卡片
if (document.getElementById('documentsThisWeek')) {
document.getElementById('documentsThisWeek').textContent = stats.basic.documentsThisWeek || '-';
}
if (document.getElementById('documentsThisMonth')) {
document.getElementById('documentsThisMonth').textContent = stats.basic.documentsThisMonth || '-';
}
if (document.getElementById('totalStorageMB')) {
document.getElementById('totalStorageMB').textContent = (stats.basic.totalStorageMB || 0) + ' MB';
}
// 显示文档状态分布
displayDocumentsByStatus(stats.documentsByStatus || []);
// 显示 Top 用户
displayTopUsers(stats.topUsers || []);
updateStatsRangeHint();
} catch (error) {
console.error('Failed to load detailed stats:', error);
}
}
function displayDocumentsByStatus(statusData) {
const container = document.getElementById('documentsByStatus');
if (!container) return;
const statusColors = {
'PENDING': 'bg-gray-100 text-gray-800',
'PROCESSING': 'bg-blue-100 text-blue-800',
'OCR_COMPLETED': 'bg-yellow-100 text-yellow-800',
'TRANSLATION_COMPLETED': 'bg-purple-100 text-purple-800',
'COMPLETED': 'bg-green-100 text-green-800',
'FAILED': 'bg-red-100 text-red-800'
};
const statusNames = {
'PENDING': '待处理',
'PROCESSING': '处理中',
'OCR_COMPLETED': 'OCR完成',
'TRANSLATION_COMPLETED': '翻译完成',
'COMPLETED': '已完成',
'FAILED': '失败'
};
container.innerHTML = statusData.map(item => `
<div class="p-4 rounded-lg border ${statusColors[item.status] || 'bg-gray-100'}">
<div class="text-xs font-medium mb-1">${statusNames[item.status] || item.status}</div>
<div class="text-2xl font-bold">${item.count}</div>
</div>
`).join('');
}
function displayTopUsers(topUsers) {
const tbody = document.getElementById('topUsersList');
if (!tbody) return;
if (topUsers.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500">暂无数据</td>
</tr>
`;
return;
}
tbody.innerHTML = topUsers.map((user, index) => `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${index < 3 ? 'bg-yellow-100 text-yellow-800 font-bold' : 'bg-gray-100 text-gray-800'}">
${index + 1}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">${escapeHtml(user.name || '-')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">${escapeHtml(user.email)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold">${user.documentCount}</td>
</tr>
`).join('');
}
// ==================== 趋势图表 ====================
let trendChartInstance = null;
async function loadTrendsChart() {
try {
// 直接传递 startDate/endDate若存在否则默认 days=30
const s = document.getElementById('statsStartDate')?.value;
const e = document.getElementById('statsEndDate')?.value;
let url = `${API_BASE}/admin/stats/trends?days=30`;
if (s || e) {
const params = new URLSearchParams();
if (s) params.set('startDate', s);
if (e) params.set('endDate', e);
url = `${API_BASE}/admin/stats/trends?${params.toString()}`;
}
const response = await axios.get(url, {
headers: { Authorization: `Bearer ${authToken}` }
});
const trends = response.data;
const ctx = document.getElementById('trendChart');
if (!ctx) return;
// 销毁旧图表
if (trendChartInstance) {
trendChartInstance.destroy();
}
// 创建新图表
trendChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: trends.map(t => {
const date = new Date(t.date);
return `${date.getMonth() + 1}/${date.getDate()}`;
}),
datasets: [
{
label: '总处理量',
data: trends.map(t => t.total),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
},
{
label: '成功',
data: trends.map(t => t.completed),
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.4
},
{
label: '失败',
data: trends.map(t => t.failed),
borderColor: 'rgb(239, 68, 68)',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'top',
},
tooltip: {
mode: 'index',
intersect: false,
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
}
}
}
}
});
} catch (error) {
console.error('Failed to load trends chart:', error);
}
}
// 应用/清除日期范围并刷新
async function applyStatsRange() {
persistRangeToHash();
await loadDetailedStats();
await loadTrendsChart();
}
async function clearStatsRange() {
const s = document.getElementById('statsStartDate');
const e = document.getElementById('statsEndDate');
if (s) s.value = '';
if (e) e.value = '';
persistRangeToHash();
await applyStatsRange();
}
window.applyStatsRange = applyStatsRange;
window.clearStatsRange = clearStatsRange;
// ==================== 配额管理 ====================
async function populateUserSelect() {
try {
const response = await axios.get(`${API_BASE}/admin/users`, {
headers: { Authorization: `Bearer ${authToken}` }
});
// 后端 /admin/users 返回 { total, page, pageSize, items }
const payload = response.data || {};
const users = Array.isArray(payload.items) ? payload.items : Array.isArray(payload) ? payload : [];
// 填充配额管理的用户选择器
const quotaSelect = document.getElementById('quotaUserId');
if (quotaSelect) {
quotaSelect.innerHTML = '<option value="">请选择用户...</option>' +
users.map(user => `
<option value="${user.id}">${escapeHtml(user.email)} - ${escapeHtml(user.name || '未设置姓名')}</option>
`).join('');
}
// 填充活动日志的用户选择器
const activitySelect = document.getElementById('activityUserId');
if (activitySelect) {
activitySelect.innerHTML = '<option value="">请选择用户...</option>' +
users.map(user => `
<option value="${user.id}">${escapeHtml(user.email)} - ${escapeHtml(user.name || '未设置姓名')}</option>
`).join('');
}
} catch (error) {
console.error('Failed to load users:', error);
}
}
async function loadUserQuota() {
const userId = document.getElementById('quotaUserId').value;
if (!userId) {
document.getElementById('quotaForm').classList.add('hidden');
return;
}
try {
const response = await axios.get(`${API_BASE}/admin/users/${userId}/quota`, {
headers: { Authorization: `Bearer ${authToken}` }
});
const quota = response.data;
// 显示表单
document.getElementById('quotaForm').classList.remove('hidden');
// 填充配额数据
document.getElementById('maxDocumentsPerDay').value = quota.maxDocumentsPerDay;
document.getElementById('maxDocumentsPerMonth').value = quota.maxDocumentsPerMonth;
document.getElementById('maxStorageSize').value = quota.maxStorageSize;
document.getElementById('maxApiKeysCount').value = quota.maxApiKeysCount;
// 显示当前使用量
document.getElementById('documentsThisMonthQuota').textContent = quota.documentsThisMonth;
document.getElementById('currentStorageUsed').textContent = quota.currentStorageUsed;
// 更新进度条
updateProgressBar('documentsProgressBar', quota.documentsThisMonth, quota.maxDocumentsPerMonth);
updateProgressBar('storageProgressBar', quota.currentStorageUsed, quota.maxStorageSize);
} catch (error) {
console.error('Failed to load quota:', error);
alert('加载配额失败:' + (error.response?.data?.error || error.message));
}
}
function updateProgressBar(elementId, current, max) {
const bar = document.getElementById(elementId);
if (!bar) return;
if (max <= 0) {
bar.style.width = '0%';
bar.classList.remove('bg-red-600');
bar.classList.add('bg-blue-600');
return;
}
const percentage = Math.min((current / max) * 100, 100);
bar.style.width = `${percentage}%`;
// 根据使用率改变颜色
if (percentage > 90) {
bar.classList.remove('bg-blue-600', 'bg-yellow-600');
bar.classList.add('bg-red-600');
} else if (percentage > 70) {
bar.classList.remove('bg-blue-600', 'bg-red-600');
bar.classList.add('bg-yellow-600');
} else {
bar.classList.remove('bg-red-600', 'bg-yellow-600');
bar.classList.add('bg-blue-600');
}
}
async function saveUserQuota() {
const userId = document.getElementById('quotaUserId').value;
if (!userId) {
alert('请先选择用户');
return;
}
const quotaData = {
maxDocumentsPerDay: parseInt(document.getElementById('maxDocumentsPerDay').value),
maxDocumentsPerMonth: parseInt(document.getElementById('maxDocumentsPerMonth').value),
maxStorageSize: parseInt(document.getElementById('maxStorageSize').value),
maxApiKeysCount: parseInt(document.getElementById('maxApiKeysCount').value)
};
try {
await axios.put(`${API_BASE}/admin/users/${userId}/quota`, quotaData, {
headers: { Authorization: `Bearer ${authToken}` }
});
alert('配额保存成功!');
await loadUserQuota(); // 重新加载以显示更新后的数据
} catch (error) {
console.error('Failed to save quota:', error);
alert('保存配额失败:' + (error.response?.data?.error || error.message));
}
}
async function resetUserQuota() {
const userId = document.getElementById('quotaUserId').value;
if (!userId) {
alert('请先选择用户');
return;
}
if (!confirm('确定要重置该用户的使用量吗?')) {
return;
}
try {
await axios.put(`${API_BASE}/admin/users/${userId}/quota`, {
documentsThisMonth: 0,
currentStorageUsed: 0,
lastMonthlyReset: new Date().toISOString()
}, {
headers: { Authorization: `Bearer ${authToken}` }
});
alert('使用量已重置!');
await loadUserQuota();
} catch (error) {
console.error('Failed to reset quota:', error);
alert('重置失败:' + (error.response?.data?.error || error.message));
}
}
// ==================== 活动日志 ====================
async function loadUserActivity() {
const userId = document.getElementById('activityUserId').value;
const limit = document.getElementById('activityLimit').value;
const tbody = document.getElementById('activityLogsList');
if (!userId) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500">
请选择用户查看活动日志
</td>
</tr>
`;
return;
}
try {
const response = await axios.get(`${API_BASE}/admin/users/${userId}/activity?limit=${limit}`, {
headers: { Authorization: `Bearer ${authToken}` }
});
const logs = response.data;
if (logs.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500">
该用户暂无活动记录
</td>
</tr>
`;
return;
}
tbody.innerHTML = logs.map(log => `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${new Date(log.createdAt).toLocaleString('zh-CN')}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs font-medium rounded-full ${getActionBadgeClass(log.action)}">
${formatActionName(log.action)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500">
${log.resourceId ? log.resourceId.substring(0, 8) + '...' : '-'}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
${formatMetadata(log.metadata)}
</td>
</tr>
`).join('');
} catch (error) {
console.error('Failed to load activity logs:', error);
tbody.innerHTML = `
<tr>
<td colspan="4" class="px-6 py-4 text-center text-red-500">
加载失败:${error.message}
</td>
</tr>
`;
}
}
function getActionBadgeClass(action) {
const classes = {
'document_create': 'bg-blue-100 text-blue-800',
'document_delete': 'bg-red-100 text-red-800',
'ocr': 'bg-purple-100 text-purple-800',
'translate': 'bg-green-100 text-green-800'
};
return classes[action] || 'bg-gray-100 text-gray-800';
}
function formatActionName(action) {
const names = {
'document_create': '创建文档',
'document_delete': '删除文档',
'ocr': 'OCR 处理',
'translate': '翻译'
};
return names[action] || action;
}
function formatMetadata(metadata) {
if (!metadata) return '-';
if (typeof metadata === 'string') return metadata;
const obj = typeof metadata === 'object' ? metadata : {};
const parts = [];
if (obj.fileName) parts.push(`文件: ${obj.fileName}`);
if (obj.fileType) parts.push(`类型: ${obj.fileType}`);
return parts.length > 0 ? parts.join(', ') : JSON.stringify(obj).substring(0, 50);
}
// ==================== 标签页切换增强 ====================
async function initOverviewStatsModule() {
try {
const mod = await import('./modules/stats.js');
await mod.initStats();
} catch (e) {
console.error('Failed to init stats module:', e);
}
}
function switchTab(tab) {
// 更新选项卡样式
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('border-blue-500', 'text-blue-600');
btn.classList.add('border-transparent', 'text-gray-500');
});
const activeTab = document.getElementById(`tab-${tab}`);
if (activeTab) {
activeTab.classList.add('border-blue-500', 'text-blue-600');
activeTab.classList.remove('border-transparent', 'text-gray-500');
}
// 切换内容
const tabs = ['overview', 'users', 'quotas', 'activity', 'models', 'system'];
tabs.forEach(t => {
const content = document.getElementById(`content-${t}`);
if (content) {
content.classList.toggle('hidden', t !== tab);
}
});
// 持久化到 URL hash
persistTabToHash(tab);
// 根据标签页加载数据
if (tab === 'overview') {
initOverviewStatsModule();
} else if (tab === 'quotas') {
(async () => { try { const m = await import('./modules/quotas.js'); await m.initQuotas(); } catch(e){ console.error(e);} })();
} else if (tab === 'activity') {
(async () => { try { const m = await import('./modules/activity.js'); await m.initActivity(); } catch(e){ console.error(e);} })();
} else if (tab === 'system') {
// 进入系统设置页时加载代理配置(动态导入模块)
(async () => { try { const m = await import('./modules/system.js'); await m.initSystem(); } catch(e){ console.error(e);} })();
} else if (tab === 'models') {
loadSourceSites();
}
}
// ==================== 初始化增强 ====================
// 扩展原有的 showAdminPanel 函数
const originalShowAdminPanel = window.showAdminPanel;
window.showAdminPanel = async function(user) {
if (originalShowAdminPanel) {
await originalShowAdminPanel(user);
}
// 初始化概览统计模块(动态导入,失败不影响其它功能)
await initOverviewStatsModule();
};
// 页面加载完成后的初始化
document.addEventListener('DOMContentLoaded', function() {
console.log('Admin panel enhancements loaded');
// 恢复 URL hash 中的日期筛选
restoreRangeFromHash();
// 若 URL 指定 tab则切换到指定 tab
restoreTabFromHash();
// 绑定模型配置的导入/导出按钮
const importBtn = document.getElementById('importSourceSitesBtn');
const importFile = document.getElementById('importSourceSitesFile');
const exportBtn = document.getElementById('exportSourceSitesBtn');
if (importBtn && importFile) {
importBtn.addEventListener('click', () => importFile.click());
importFile.addEventListener('change', async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const json = JSON.parse(text);
await importSourceSites(json);
await loadSourceSites();
alert('导入完成');
} catch (err) {
console.error('Import failed:', err);
alert('导入失败:' + err.message);
} finally {
e.target.value = '';
}
});
}
if (exportBtn) {
exportBtn.addEventListener('click', exportSourceSites);
}
});
// ==================== 代理设置(系统设置) ====================
async function loadProxySettings() {
try {
const resp = await axios.get(`${API_BASE}/admin/config`, { headers: { Authorization: `Bearer ${authToken}` } });
const cfg = resp.data || {};
// 回显
// 通用系统设置
setSelectValue('allowRegistration', (cfg.ALLOW_REGISTRATION || 'false').toString());
setInputValue('maxUploadSize', cfg.MAX_UPLOAD_SIZE_MB || '100');
// 代理与下载相关
setInputValue('proxyWhitelistDomains', cfg.PROXY_WHITELIST_DOMAINS || '');
setInputValue('workerProxyDomains', cfg.WORKER_PROXY_DOMAINS || '');
setSelectValue('allowHttpProxy', (cfg.ALLOW_HTTP_PROXY || 'false').toString());
setInputValue('ocrUpstreamTimeoutMs', cfg.OCR_UPSTREAM_TIMEOUT_MS || '30000');
setInputValue('maxProxyDownloadMb', cfg.MAX_PROXY_DOWNLOAD_MB || '100');
// 读取有效配置与来源拆解并渲染
const eff = await axios.get(`${API_BASE}/admin/proxy-settings/effective`, { headers: { Authorization: `Bearer ${authToken}` } });
renderEffectiveProxySettings(eff.data);
} catch (e) {
console.error('Failed to load proxy settings:', e);
}
}
// 保存通用系统设置(允许注册、最大上传大小)
async function saveSystemSettings() {
try {
const entries = [
{ key: 'ALLOW_REGISTRATION', value: getSelectValue('allowRegistration') },
{ key: 'MAX_UPLOAD_SIZE_MB', value: String(parseInt(getInputValue('maxUploadSize') || '100')) },
];
for (const item of entries) {
await axios.put(`${API_BASE}/admin/config`, item, { headers: { Authorization: `Bearer ${authToken}` } });
}
alert('系统设置已保存');
} catch (e) {
console.error('Failed to save system settings:', e);
alert('保存失败:' + (e.response?.data?.error || e.message));
}
}
async function saveProxySettings() {
try {
const entries = [
{ key: 'PROXY_WHITELIST_DOMAINS', value: getInputValue('proxyWhitelistDomains') },
{ key: 'WORKER_PROXY_DOMAINS', value: getInputValue('workerProxyDomains') },
{ key: 'ALLOW_HTTP_PROXY', value: getSelectValue('allowHttpProxy') },
{ key: 'OCR_UPSTREAM_TIMEOUT_MS', value: String(parseInt(getInputValue('ocrUpstreamTimeoutMs') || '30000')) },
{ key: 'MAX_PROXY_DOWNLOAD_MB', value: String(parseInt(getInputValue('maxProxyDownloadMb') || '100')) },
];
for (const item of entries) {
await axios.put(`${API_BASE}/admin/config`, item, { headers: { Authorization: `Bearer ${authToken}` } });
}
alert('已保存,约 60 秒内生效');
} catch (e) {
console.error('Failed to save proxy settings:', e);
alert('保存失败:' + (e.response?.data?.error || e.message));
}
}
function setInputValue(id, val) { const el = document.getElementById(id); if (el) el.value = val ?? ''; }
function getInputValue(id) { const el = document.getElementById(id); return el ? el.value : ''; }
function setSelectValue(id, val) { const el = document.getElementById(id); if (el) el.value = val; }
function getSelectValue(id) { const el = document.getElementById(id); return el ? el.value : ''; }
// ==================== 自定义源站(模型配置) ====================
async function loadSourceSites() {
try {
const resp = await axios.get(`${API_BASE}/admin/source-sites`, { headers: { Authorization: `Bearer ${authToken}` } });
renderSourceSitesList(resp.data || []);
} catch (e) {
console.error('Failed to load source sites:', e);
const list = document.getElementById('sourceSitesList');
if (list) list.innerHTML = `<div class="text-sm text-red-600">加载失败:${e.message}</div>`;
}
}
function renderSourceSitesList(sites) {
const list = document.getElementById('sourceSitesList');
if (!list) return;
if (!Array.isArray(sites) || sites.length === 0) {
list.innerHTML = `<div class="text-gray-500 text-sm">暂无自定义源站。</div>`;
return;
}
list.innerHTML = sites.map(site => `
<div class="bg-white border rounded-lg p-4">
<div class="flex items-center justify-between">
<div>
<div class="text-base font-medium">${escapeHtml(site.displayName || '-')}</div>
<div class="text-xs text-gray-600 mt-1">${escapeHtml(site.apiBaseUrl || '-')}</div>
<div class="text-xs text-gray-500 mt-1">格式: ${escapeHtml(site.requestFormat || 'openai')} · 温度: ${site.temperature ?? 0.5} · MaxTokens: ${site.maxTokens ?? 8000}</div>
</div>
<div class="flex items-center gap-3">
<button class="text-blue-600 hover:text-blue-800" onclick="editSourceSite('${site.id}')">编辑</button>
<button class="text-red-600 hover:text-red-800" onclick="deleteSourceSite('${site.id}')">删除</button>
</div>
</div>
<div class="text-xs text-gray-500 mt-2">提示:该域将自动加入代理白名单。</div>
</div>
`).join('');
}
async function addSourceSite() {
try {
const displayName = prompt('源站名称:'); if (!displayName) return;
const apiBaseUrl = prompt('API 基础地址https://...'); if (!apiBaseUrl) return;
const requestFormat = prompt('请求格式openai/anthropic/custom默认 openai') || 'openai';
const temperature = parseFloat(prompt('温度0.0~2.0,默认 0.5') || '0.5');
const maxTokens = parseInt(prompt('最大 Tokens默认 8000') || '8000');
const modelsCsv = prompt('可用模型(逗号分隔,可留空):') || '';
const availableModels = modelsCsv ? modelsCsv.split(',').map(s => s.trim()).filter(Boolean) : [];
const payload = { displayName, apiBaseUrl, requestFormat, temperature, maxTokens, availableModels };
await axios.post(`${API_BASE}/admin/source-sites`, payload, { headers: { Authorization: `Bearer ${authToken}` } });
await loadSourceSites();
alert('已添加');
} catch (e) {
alert('添加失败:' + (e.response?.data?.error || e.message));
}
}
async function editSourceSite(id) {
try {
// 获取当前项
const resp = await axios.get(`${API_BASE}/admin/source-sites`, { headers: { Authorization: `Bearer ${authToken}` } });
const site = (resp.data || []).find(s => s.id === id);
if (!site) return alert('未找到该源站');
const displayName = prompt('源站名称:', site.displayName || '') ?? site.displayName;
const apiBaseUrl = prompt('API 基础地址https://...', site.apiBaseUrl || '') ?? site.apiBaseUrl;
const requestFormat = prompt('请求格式openai/anthropic/custom', site.requestFormat || 'openai') || site.requestFormat;
const temperature = parseFloat(prompt('温度0.0~2.0', String(site.temperature ?? '0.5')) || site.temperature);
const maxTokens = parseInt(prompt('最大 Tokens', String(site.maxTokens ?? '8000')) || site.maxTokens);
const modelsCsv = prompt('可用模型(逗号分隔):', Array.isArray(site.availableModels) ? site.availableModels.join(',') : '') || '';
const availableModels = modelsCsv ? modelsCsv.split(',').map(s => s.trim()).filter(Boolean) : [];
const payload = { displayName, apiBaseUrl, requestFormat, temperature, maxTokens, availableModels };
await axios.put(`${API_BASE}/admin/source-sites/${id}`, payload, { headers: { Authorization: `Bearer ${authToken}` } });
await loadSourceSites();
alert('已保存');
} catch (e) {
alert('保存失败:' + (e.response?.data?.error || e.message));
}
}
async function deleteSourceSite(id) {
if (!confirm('确定要删除该源站?')) return;
try {
await axios.delete(`${API_BASE}/admin/source-sites/${id}`, { headers: { Authorization: `Bearer ${authToken}` } });
await loadSourceSites();
alert('已删除');
} catch (e) {
alert('删除失败:' + (e.response?.data?.error || e.message));
}
}
// 暴露到全局index.html 的按钮会调用)
window.addSourceSite = addSourceSite;
window.editSourceSite = editSourceSite;
window.deleteSourceSite = deleteSourceSite;
// 导入/导出源站点 JSON
async function importSourceSites(data) {
// 支持对象或数组格式
let items = [];
if (Array.isArray(data)) {
items = data;
} else if (data && typeof data === 'object') {
// 兼容 { items: [...] } 或 { customSourceSites: {...} }
if (Array.isArray(data.items)) {
items = data.items;
} else if (Array.isArray(data.customSourceSites)) {
items = data.customSourceSites;
} else if (data.customSourceSites && typeof data.customSourceSites === 'object') {
// 对象转数组
items = Object.values(data.customSourceSites);
} else {
items = Object.values(data);
}
}
if (!Array.isArray(items)) throw new Error('无效的导入格式');
// 逐条创建(后端已做校验/截断)
for (const raw of items) {
const payload = normalizeSitePayload(raw);
if (!payload) continue;
try {
await axios.post(`${API_BASE}/admin/source-sites`, payload, { headers: { Authorization: `Bearer ${authToken}` } });
} catch (e) {
console.warn('Skip one item due to error:', e.response?.data || e.message);
}
}
}
function normalizeSitePayload(raw) {
if (!raw || typeof raw !== 'object') return null;
const displayName = raw.displayName || raw.name || '';
const apiBaseUrl = raw.apiBaseUrl || raw.baseUrl || raw.endpoint || '';
if (!displayName || !apiBaseUrl) return null;
const requestFormat = raw.requestFormat || 'openai';
const temperature = Number.isFinite(raw.temperature) ? Number(raw.temperature) : 0.5;
const maxTokens = Number.isFinite(raw.maxTokens) ? Number(raw.maxTokens) : 8000;
const availableModels = Array.isArray(raw.availableModels) ? raw.availableModels : [];
return { displayName, apiBaseUrl, requestFormat, temperature, maxTokens, availableModels };
}
async function exportSourceSites() {
try {
const resp = await axios.get(`${API_BASE}/admin/source-sites`, { headers: { Authorization: `Bearer ${authToken}` } });
const items = resp.data || [];
const blob = new Blob([JSON.stringify({ items }, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `source-sites-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error('Export failed:', e);
alert('导出失败:' + (e.response?.data?.error || e.message));
}
}
function renderEffectiveProxySettings(d) {
const containerId = 'effectiveProxySettings';
let container = document.getElementById(containerId);
if (!container) {
const sys = document.getElementById('content-system');
if (!sys) return;
container = document.createElement('div');
container.id = containerId;
container.className = 'mt-6 bg-gray-50 border rounded-md p-4';
sys.appendChild(container);
}
try {
const sources = d?.sources || {};
const eff = d?.effective || {};
const nl = arr => Array.isArray(arr) && arr.length ? arr.map(x => `<code class="px-1 py-0.5 bg-white border rounded">${escapeHtml(String(x))}</code>`).join(' ') : '<span class="text-gray-400">(空)</span>';
container.innerHTML = `
<h4 class="text-sm font-medium text-gray-800 mb-2">当前生效的代理设置(合并 + 来源)</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div class="bg-white rounded border p-3">
<div class="font-medium text-gray-700 mb-1">合并后白名单</div>
<div class="space-x-1">${nl(eff.whitelist)}</div>
</div>
<div class="bg-white rounded border p-3">
<div class="font-medium text-gray-700 mb-1">开关与限制</div>
<div class="text-gray-600">允许 HTTP${eff.allowHttp ? '是' : '否'}</div>
<div class="text-gray-600">上游超时:${eff.timeoutMs} ms</div>
<div class="text-gray-600">下载上限:${Math.round((eff.maxDownloadBytes||0)/1024/1024)} MB</div>
</div>
<div class="bg-white rounded border p-3 md:col-span-2">
<div class="font-medium text-gray-700 mb-1">来源拆解</div>
<div class="mt-1"><span class="text-gray-600">默认域:</span>${nl(sources.defaults)}</div>
<div class="mt-1"><span class="text-gray-600">手动白名单:</span>${nl(sources.manualWhitelist)}</div>
<div class="mt-1"><span class="text-gray-600">Workers 域:</span>${nl(sources.workerDomains)}</div>
<div class="mt-1"><span class="text-gray-600">自定义源站域:</span>${nl(sources.customSiteDomains)}</div>
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="text-sm text-red-600">无法渲染有效配置:${e.message}</div>`;
}
}
async function refreshEffectiveProxySettings() {
try {
const eff = await axios.get(`${API_BASE}/admin/proxy-settings/effective`, { headers: { Authorization: `Bearer ${authToken}` } });
renderEffectiveProxySettings(eff.data);
} catch (e) {
alert('刷新失败:' + (e.response?.data?.error || e.message));
}
}
window.refreshEffectiveProxySettings = refreshEffectiveProxySettings;
async function applyProxySettingsNow() {
try {
await axios.post(`${API_BASE}/admin/proxy-settings/apply-now`, {}, { headers: { Authorization: `Bearer ${authToken}` } });
await refreshEffectiveProxySettings();
alert('已立即应用配置(缓存清空)');
} catch (e) {
alert('操作失败:' + (e.response?.data?.error || e.message));
}
}
async function clearProxyDomains() {
if (!confirm('确定清空“代理白名单域手动”与“Workers 代理域”吗?')) return;
try {
const entries = [
{ key: 'PROXY_WHITELIST_DOMAINS', value: '' },
{ key: 'WORKER_PROXY_DOMAINS', value: '' },
];
for (const item of entries) {
await axios.put(`${API_BASE}/admin/config`, item, { headers: { Authorization: `Bearer ${authToken}` } });
}
await applyProxySettingsNow();
// 同步表单显示
setInputValue('proxyWhitelistDomains', '');
setInputValue('workerProxyDomains', '');
} catch (e) {
alert('清空失败:' + (e.response?.data?.error || e.message));
}
}
window.applyProxySettingsNow = applyProxySettingsNow;
window.clearProxyDomains = clearProxyDomains;