// 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 => `
${statusNames[item.status] || item.status}
${item.count}
`).join(''); } function displayTopUsers(topUsers) { const tbody = document.getElementById('topUsersList'); if (!tbody) return; if (topUsers.length === 0) { tbody.innerHTML = ` 暂无数据 `; return; } tbody.innerHTML = topUsers.map((user, index) => ` ${index + 1} ${escapeHtml(user.name || '-')} ${escapeHtml(user.email)} ${user.documentCount} `).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 = '' + users.map(user => ` `).join(''); } // 填充活动日志的用户选择器 const activitySelect = document.getElementById('activityUserId'); if (activitySelect) { activitySelect.innerHTML = '' + users.map(user => ` `).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 = ` 请选择用户查看活动日志 `; 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 = ` 该用户暂无活动记录 `; return; } tbody.innerHTML = logs.map(log => ` ${new Date(log.createdAt).toLocaleString('zh-CN')} ${formatActionName(log.action)} ${log.resourceId ? log.resourceId.substring(0, 8) + '...' : '-'} ${formatMetadata(log.metadata)} `).join(''); } catch (error) { console.error('Failed to load activity logs:', error); tbody.innerHTML = ` 加载失败:${error.message} `; } } 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 = `
加载失败:${e.message}
`; } } function renderSourceSitesList(sites) { const list = document.getElementById('sourceSitesList'); if (!list) return; if (!Array.isArray(sites) || sites.length === 0) { list.innerHTML = `
暂无自定义源站。
`; return; } list.innerHTML = sites.map(site => `
${escapeHtml(site.displayName || '-')}
${escapeHtml(site.apiBaseUrl || '-')}
格式: ${escapeHtml(site.requestFormat || 'openai')} · 温度: ${site.temperature ?? 0.5} · MaxTokens: ${site.maxTokens ?? 8000}
提示:该域将自动加入代理白名单。
`).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 => `${escapeHtml(String(x))}`).join(' ') : '(空)'; container.innerHTML = `

当前生效的代理设置(合并 + 来源)

合并后白名单
${nl(eff.whitelist)}
开关与限制
允许 HTTP:${eff.allowHttp ? '是' : '否'}
上游超时:${eff.timeoutMs} ms
下载上限:${Math.round((eff.maxDownloadBytes||0)/1024/1024)} MB
来源拆解
默认域:${nl(sources.defaults)}
手动白名单:${nl(sources.manualWhitelist)}
Workers 域:${nl(sources.workerDomains)}
自定义源站域:${nl(sources.customSiteDomains)}
`; } catch (e) { container.innerHTML = `
无法渲染有效配置:${e.message}
`; } } 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;