/* ===== Auth ===== */ const TOKEN = localStorage.getItem('adminToken'); const api = (url, opts = {}) => { opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' }; return fetch(url, opts).then(r => { if (r.status === 401) { logout(); throw new Error('unauthorized'); } return r.json(); }); }; function checkAuth() { if (!TOKEN) { location.href = '/static/login.html'; return; } api('/admin/status').catch(() => logout()); } function logout() { localStorage.removeItem('adminToken'); location.href = '/static/login.html'; } /* ===== Toast ===== */ function showToast(msg, type = 'info') { const colors = { success: 'bg-green-600', error: 'bg-red-600', info: 'bg-gray-900' }; const el = document.createElement('div'); el.className = `fixed bottom-4 right-4 ${colors[type] || colors.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-[200] animate-slide-up`; el.textContent = msg; document.body.appendChild(el); setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 300); }, 2000); } /* ===== Tabs ===== */ function switchTab(tab) { ['accounts', 'settings', 'chat'].forEach(t => { document.getElementById('panel' + t.charAt(0).toUpperCase() + t.slice(1)).classList.toggle('hidden', t !== tab); const btn = document.getElementById('tab' + t.charAt(0).toUpperCase() + t.slice(1)); btn.classList.toggle('border-primary', t === tab); btn.classList.toggle('border-transparent', t !== tab); btn.classList.toggle('text-muted-foreground', t !== tab); }); if (tab === 'settings') loadSettings(); if (tab === 'accounts') loadAccounts(); if (tab === 'chat') loadChatModels(); } /* ===== Accounts ===== */ let _accounts = []; function loadAccounts() { api('/admin/accounts').then(d => { _accounts = d.accounts || []; const s = d.stats || {}; document.getElementById('statTotal').textContent = s.total ?? _accounts.length; document.getElementById('statActive').textContent = s.active ?? '-'; document.getElementById('statCost').textContent = '$' + (s.cost ?? 0).toFixed(2); document.getElementById('statRequests').textContent = s.requests ?? '-'; renderAccounts(); }).catch(() => showToast('加载账号失败', 'error')); } let _pageSize = 20; let _currentPage = 1; function renderAccounts() { const box = document.getElementById('accountList'); const filter = document.getElementById('accountFilter').value; const filtered = _accounts.map((a, i) => ({ ...a, _idx: i })).filter(a => { if (filter === 'active') return a.active === true; if (filter === 'expired') return a.active !== true; return true; }); const totalPages = Math.max(1, Math.ceil(filtered.length / _pageSize)); if (_currentPage > totalPages) _currentPage = totalPages; const start = (_currentPage - 1) * _pageSize; const paged = filtered.slice(start, start + _pageSize); if (!filtered.length) { box.innerHTML = `${_accounts.length ? '无匹配账号' : '暂无账号,点击右上角添加'}`; } else { box.innerHTML = paged.map(a => { const active = a.active === true; const atTag = a.at_mask ? `${a.at_mask}` : ''; const rtTag = a.rt_mask ? `${a.rt_mask}` : ''; const statusHtml = active ? `` : `已过期`; const checked = _selected.has(a._idx) ? 'checked' : ''; return ` ${a.email || '未知邮箱'} ${atTag} ${rtTag} ${statusHtml} `; }).join(''); } const activeCount = _accounts.filter(a => a.active === true).length; const selCount = _selected.size; const selText = selCount ? `已选 ${selCount} | ` : ''; // pagination let pageHtml = ''; if (totalPages > 1) { pageHtml = ` ${_currentPage}/${totalPages} `; } document.getElementById('accountFooter').innerHTML = `
${selText}显示 ${filtered.length} / ${_accounts.length} 个账号(活跃 ${activeCount})
${pageHtml}
`; document.getElementById('selectAll').checked = paged.length > 0 && paged.every(a => _selected.has(a._idx)); updateCountdowns(); } let _selected = new Set(); function toggleSelect(idx) { _selected.has(idx) ? _selected.delete(idx) : _selected.add(idx); renderAccounts(); } function toggleSelectAll() { const all = document.getElementById('selectAll').checked; const filter = document.getElementById('accountFilter').value; const filtered = _accounts.map((a, i) => ({ ...a, _idx: i })).filter(a => { if (filter === 'active') return a.active === true; if (filter === 'expired') return a.active !== true; return true; }); const start = (_currentPage - 1) * _pageSize; const paged = filtered.slice(start, start + _pageSize); paged.forEach(a => all ? _selected.add(a._idx) : _selected.delete(a._idx)); renderAccounts(); } function getSelectedIndices() { return [..._selected]; } function changePageSize(v) { _pageSize = parseInt(v); _currentPage = 1; renderAccounts(); } function changePage(delta) { _currentPage += delta; renderAccounts(); } function updateCountdowns() { document.querySelectorAll('[data-expires]').forEach(el => { const exp = parseInt(el.dataset.expires); const diff = exp - Date.now(); if (diff <= 0) { el.textContent = '已过期'; el.className = 'text-red-500'; return; } const d = Math.floor(diff / 86400000); const h = Math.floor((diff % 86400000) / 3600000); const m = Math.floor((diff % 3600000) / 60000); let text = ''; if (d > 0) text += d + '天'; text += h + 'h ' + m + 'm'; el.textContent = text; if (d < 1) el.className = 'text-orange-500'; }); } if (!window._countdownTimer) window._countdownTimer = setInterval(updateCountdowns, 1000); function refreshAccount(idx) { api(`/admin/accounts/${idx}/refresh`, { method: 'POST' }).then(d => { d.ok ? showToast('刷新成功', 'success') : showToast(d.error || '刷新失败', 'error'); loadAccounts(); }); } function removeAccount(idx) { if (!confirm('确定删除该账号?')) return; api(`/admin/accounts/${idx}`, { method: 'DELETE' }).then(() => { showToast('已删除', 'success'); loadAccounts(); }); } function refreshAllAccounts() { api('/admin/refresh', { method: 'POST' }).then(d => { d.ok ? showToast('全部刷新完成', 'success') : showToast('刷新失败', 'error'); loadAccounts(); }); } function batchDeleteAccounts() { if (!confirm('确定删除所有账号?')) return; const indices = _accounts.map((_, i) => i); api('/admin/accounts/batch-delete', { method: 'POST', body: JSON.stringify({ indices }) }).then(d => { showToast(`已删除 ${d.removed} 个账号`, 'success'); loadAccounts(); }); } function exportAccounts() { api('/admin/accounts/export', { method: 'POST' }).then(d => { const blob = new Blob([JSON.stringify(d.accounts, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'ob1_accounts.json'; a.click(); showToast('导出成功', 'success'); }); } function importAccounts() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = ev => { try { const accounts = JSON.parse(ev.target.result); api('/admin/accounts/import', { method: 'POST', body: JSON.stringify({ accounts: Array.isArray(accounts) ? accounts : [accounts] }) }) .then(d => { showToast(`导入 ${d.imported} 个账号`, 'success'); loadAccounts(); }); } catch { showToast('JSON 格式错误', 'error'); } }; reader.readAsText(file); }; input.click(); } /* ===== Device Auth (Add Account) ===== */ let _pollTimer = null; function openDeviceAuth() { document.getElementById('deviceAuthModal').classList.remove('hidden'); document.getElementById('deviceAuthContent').classList.remove('hidden'); document.getElementById('deviceAuthPending').classList.add('hidden'); } function startDeviceAuth() { document.getElementById('deviceAuthContent').classList.add('hidden'); document.getElementById('deviceAuthPending').classList.remove('hidden'); api('/admin/device-auth', { method: 'POST' }).then(d => { if (d.error) { document.getElementById('deviceAuthContent').classList.remove('hidden'); document.getElementById('deviceAuthPending').classList.add('hidden'); showToast(d.error, 'error'); return; } const link = document.getElementById('deviceAuthLink'); link.href = d.verification_uri_complete || d.verification_uri; link.textContent = d.verification_uri_complete || d.verification_uri; document.getElementById('deviceAuthCode').textContent = d.user_code || ''; pollDeviceAuth(d.device_code, d.interval || 5); }).catch(() => { document.getElementById('deviceAuthContent').classList.remove('hidden'); document.getElementById('deviceAuthPending').classList.add('hidden'); showToast('获取授权失败', 'error'); }); } function pollDeviceAuth(code, interval) { clearInterval(_pollTimer); _pollTimer = setInterval(() => { api('/admin/device-auth/poll', { method: 'POST', body: JSON.stringify({ device_code: code }) }).then(d => { if (d.status === 'complete') { clearInterval(_pollTimer); closeDeviceAuth(); showToast(`已添加账号: ${d.email}`, 'success'); loadAccounts(); } else if (d.status === 'expired' || d.status === 'error') { clearInterval(_pollTimer); showToast(d.message || '授权失败', 'error'); } }); }, interval * 1000); } function closeDeviceAuth() { clearInterval(_pollTimer); document.getElementById('deviceAuthModal').classList.add('hidden'); } /* ===== Settings ===== */ function loadSettings() { api('/admin/settings').then(d => { document.getElementById('cfgUsername').value = d.username || ''; document.getElementById('cfgCurrentKey').value = d.api_key || ''; document.getElementById('cfgProxy').value = d.proxy_url || ''; selectRotation(d.rotation_mode || 'cache-first', false); document.getElementById('cfgDebugLog').checked = (d.log_level || 'INFO') === 'DEBUG'; document.getElementById('cfgRefreshInterval').value = d.refresh_interval || 0; }); } function updatePassword() { const old_password = document.getElementById('cfgOldPwd').value; const new_password = document.getElementById('cfgNewPwd').value; if (!old_password || !new_password) { showToast('请填写完整', 'error'); return; } api('/admin/settings/password', { method: 'POST', body: JSON.stringify({ old_password, new_password }) }).then(d => { d.ok ? (showToast('密码已更新', 'success'), document.getElementById('cfgOldPwd').value = '', document.getElementById('cfgNewPwd').value = '') : showToast(d.message || '更新失败', 'error'); }); } function updateUsername() { const username = document.getElementById('cfgUsername').value.trim(); if (!username) return; api('/admin/settings/username', { method: 'POST', body: JSON.stringify({ username }) }).then(d => { d.ok ? showToast('用户名已更新', 'success') : showToast('更新失败', 'error'); }); } function updateAPIKey() { const api_key = document.getElementById('cfgNewKey').value.trim(); if (!api_key) return; api('/admin/settings/api-key', { method: 'POST', body: JSON.stringify({ api_key }) }).then(d => { d.ok ? showToast('API Key 已更新', 'success') : showToast('更新失败', 'error'); }); } function updateProxy() { const url = document.getElementById('cfgProxy').value.trim(); api('/admin/settings/proxy', { method: 'POST', body: JSON.stringify({ url }) }).then(d => { d.ok ? showToast('代理已更新', 'success') : showToast('更新失败', 'error'); }); } function testProxy() { const url = document.getElementById('cfgProxy').value.trim(); if (!url) { showToast('请先填写代理地址', 'error'); return; } const btn = document.getElementById('btnTestProxy'); btn.disabled = true; btn.textContent = '测试中...'; api('/admin/settings/proxy-test', { method: 'POST', body: JSON.stringify({ url }) }).then(d => { if (d.ok) showToast('代理可用,IP: ' + d.ip, 'success'); else showToast('代理不可用: ' + d.error, 'error'); }).catch(() => showToast('测试请求失败', 'error')) .finally(() => { btn.disabled = false; btn.textContent = '测试'; }); } function selectRotation(mode, save = true) { document.getElementById('cfgRotationMode').value = mode; if (save) { api('/admin/settings/rotation-mode', { method: 'POST', body: JSON.stringify({ mode }) }).then(d => { d.ok ? showToast('调度模式已更新', 'success') : showToast(d.message || '更新失败', 'error'); }); } } function toggleDebugLog() { const level = document.getElementById('cfgDebugLog').checked ? 'DEBUG' : 'INFO'; api('/admin/settings/log-level', { method: 'POST', body: JSON.stringify({ level }) }).then(d => { d.ok ? showToast(level === 'DEBUG' ? '调试日志已开启' : '调试日志已关闭', 'success') : showToast(d.message || '更新失败', 'error'); }); } function updateRefreshInterval() { const interval = parseInt(document.getElementById('cfgRefreshInterval').value) || 0; api('/admin/settings/refresh-interval', { method: 'POST', body: JSON.stringify({ interval }) }).then(d => { d.ok ? showToast(interval > 0 ? `自动续期检查已设置为 ${interval} 分钟` : '自动续期已关闭', 'success') : showToast(d.message || '更新失败', 'error'); }); } /* ===== Chat ===== */ let _chatMessages = []; const TOP_MODELS = [ 'anthropic/claude-opus-4.6', 'anthropic/claude-sonnet-4.6', 'openai/gpt-5.4-pro', 'google/gemini-3.1-flash-image-preview', 'openai/gpt-5.3-codex', 'x-ai/grok-4.1-fast', 'qwen/qwen-3.5-397b', ]; function _shortName(id) { return id.includes('/') ? id.split('/').pop() : id; } function loadChatModels() { const sel = document.getElementById('chatModel'); const prev = sel.value; fetch('/v1/models', { headers: { 'Authorization': 'Bearer ' + TOKEN } }) .then(r => r.json()) .then(d => { const apiIds = (d.data || []).map(m => m.id); _fillModelSelect(sel, apiIds, prev); }) .catch(() => _fillModelSelect(sel, [], prev)); } function _fillModelSelect(sel, apiIds, prev) { const all = new Set([...TOP_MODELS, ...apiIds]); const topSet = new Set(TOP_MODELS); const rest = [...all].filter(id => !topSet.has(id)).sort(); let html = ''; html += TOP_MODELS.map(id => ``).join(''); html += ''; if (rest.length) { html += ''; html += rest.map(id => ``).join(''); html += ''; } sel.innerHTML = html; sel.value = prev && all.has(prev) ? prev : TOP_MODELS[0]; } function _parseAssistantMsg(msg) { const content = msg.content; const result = { role: 'assistant', content: '', images: [] }; if (typeof content === 'string') { result.content = content || ''; } else if (Array.isArray(content)) { for (const part of content) { if (part.type === 'text') result.content += part.text || ''; else if (part.type === 'image_url') result.images.push(part.image_url?.url || ''); } } // OB1/OpenRouter puts images in message.images if (Array.isArray(msg.images)) { for (const img of msg.images) { if (img.image_url?.url) result.images.push(img.image_url.url); else if (typeof img === 'string') result.images.push(img); } } if (!result.content && !result.images.length) result.content = 'No response'; return result; } function sendChat() { const input = document.getElementById('chatInput'); const msg = input.value.trim(); if (!msg) return; input.value = ''; _chatMessages.push({ role: 'user', content: msg }); renderChat(); const model = document.getElementById('chatModel').value; const stream = document.getElementById('chatStream').checked; if (stream) { streamChat(model, msg); } else { api('/v1/chat/completions', { method: 'POST', body: JSON.stringify({ model, messages: _chatMessages, stream: false }) }).then(d => { const msg = d.choices?.[0]?.message || {}; const parsed = _parseAssistantMsg(msg); _chatMessages.push(parsed); renderChat(); }).catch(() => { _chatMessages.push({ role: 'assistant', content: '请求失败' }); renderChat(); }); } } function streamChat(model, msg) { const assistantMsg = { role: 'assistant', content: '' }; _chatMessages.push(assistantMsg); renderChat(); fetch('/v1/chat/completions', { method: 'POST', headers: { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages: _chatMessages.slice(0, -1), stream: true }) }).then(resp => { const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buf = ''; function read() { reader.read().then(({ done, value }) => { if (done) { renderChat(); return; } buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ') || line === 'data: [DONE]') continue; try { const j = JSON.parse(line.slice(6)); const delta = j.choices?.[0]?.delta?.content; if (delta) { assistantMsg.content += delta; renderChat(); } } catch {} } read(); }); } read(); }).catch(() => { assistantMsg.content = '流式请求失败'; renderChat(); }); } function renderChat() { const box = document.getElementById('chatMessages'); box.innerHTML = _chatMessages.map(m => { const isUser = m.role === 'user'; let rendered = typeof marked !== 'undefined' ? marked.parse(m.content || '') : (m.content || ''); if (m.images && m.images.length) { rendered += m.images.map(url => `生成图片`).join(''); } return `
${rendered}
`; }).join(''); box.scrollTop = box.scrollHeight; document.querySelectorAll('.chat-msg pre code').forEach(el => hljs.highlightElement(el)); } function clearChat() { _chatMessages = []; document.getElementById('chatMessages').innerHTML = ''; } /* ===== Init ===== */ window.addEventListener('DOMContentLoaded', () => { checkAuth(); loadAccounts(); document.getElementById('chatInput').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } }); });