noke / static /index.html
i3t2y
project rename: tt to noke
b461de0
Raw
History Blame Contribute Delete
39.5 kB
<!DOCTYPE html>
<html lang="zh" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Noke 管理面板</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
border: 'hsl(240 3.7% 15.9%)',
input: 'hsl(240 3.7% 15.9%)',
ring: 'hsl(240 4.9% 83.9%)',
background: 'hsl(240 10% 3.9%)',
foreground: 'hsl(0 0% 98%)',
primary: { DEFAULT: 'hsl(0 0% 98%)', foreground: 'hsl(240 5.9% 10%)' },
muted: { DEFAULT: 'hsl(240 3.7% 15.9%)', foreground: 'hsl(240 5% 64.9%)' },
accent: { DEFAULT: 'hsl(240 3.7% 15.9%)', foreground: 'hsl(0 0% 98%)' },
destructive: { DEFAULT: 'hsl(0 62.8% 30.6%)', foreground: 'hsl(0 0% 98%)' },
card: { DEFAULT: 'hsl(240 10% 3.9%)', foreground: 'hsl(0 0% 98%)' },
}
}
}
}
</script>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.status-active { background: #22c55e; box-shadow: 0 0 6px #22c55e88; }
.status-error { background: #ef4444; box-shadow: 0 0 6px #ef444488; }
.status-unknown { background: #71717a; }
.status-cooldown { background: #eab308; box-shadow: 0 0 6px #eab30888; }
.fade-in { animation: fadeIn .2s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
input:focus, select:focus, textarea:focus { outline: none; box-shadow: 0 0 0 2px hsl(240 4.9% 83.9% / 0.3); }
.nav-item { transition: background .15s, color .15s; }
.nav-item:hover { background: hsl(240 3.7% 15.9%); }
.nav-item.active { background: hsl(240 3.7% 15.9%); color: hsl(0 0% 98%); }
</style>
</head>
<body class="bg-zinc-950 text-zinc-100 min-h-screen">
<div id="app"></div>
<script>
const API = '/api/admin';
let token = localStorage.getItem('admin_token') || '';
let currentPage = 'dashboard';
let autoRefreshTimer = null;
// ── Fetch helpers ──
async function api(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...opts.headers };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(API + path, { ...opts, headers });
if (res.status === 401) {
token = '';
localStorage.removeItem('admin_token');
render();
throw new Error('unauthorized');
}
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'request failed');
return data;
}
// ── Router ──
function navigate(page) {
currentPage = page;
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
render();
}
function render() {
const app = document.getElementById('app');
if (!token) { app.innerHTML = loginPage(); bindLogin(); return; }
app.innerHTML = layout();
bindNav();
loadPage();
}
// ── Login ──
function loginPage() {
return `
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-8 shadow-2xl fade-in">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-lg bg-zinc-800 mb-4">
<svg class="w-6 h-6 text-zinc-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
</div>
<h1 class="text-xl font-semibold">Noke</h1>
<p class="text-sm text-zinc-400 mt-1">管理面板登录</p>
</div>
<form id="loginForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-zinc-400 mb-1.5">密码</label>
<input id="loginPwd" type="password" placeholder="输入管理密码" autocomplete="current-password"
class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:border-zinc-600">
</div>
<button type="submit" class="w-full rounded-lg bg-zinc-100 px-4 py-2.5 text-sm font-medium text-zinc-900 hover:bg-zinc-200 transition-colors">
登录
</button>
<p id="loginErr" class="text-sm text-red-400 text-center hidden"></p>
</form>
</div>
</div>
</div>`;
}
function bindLogin() {
document.getElementById('loginForm').onsubmit = async (e) => {
e.preventDefault();
const pwd = document.getElementById('loginPwd').value;
const errEl = document.getElementById('loginErr');
try {
const data = await fetch(API + '/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pwd })
}).then(r => r.json());
if (data.token) {
token = data.token;
localStorage.setItem('admin_token', token);
currentPage = 'dashboard';
render();
} else {
errEl.textContent = data.detail || '登录失败';
errEl.classList.remove('hidden');
}
} catch {
errEl.textContent = '连接失败';
errEl.classList.remove('hidden');
}
};
}
// ── Layout ──
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>' },
{ id: 'tokens', label: 'Tokens', icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>' },
{ id: 'settings', label: 'Settings', icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>' },
{ id: 'logs', label: 'Logs', icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>' },
];
function layout() {
return `
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside class="w-56 border-r border-zinc-800 bg-zinc-950 flex flex-col fixed h-screen">
<div class="p-5 border-b border-zinc-800">
<h1 class="text-base font-semibold tracking-tight">Noke</h1>
<p class="text-xs text-zinc-500 mt-0.5">管理面板</p>
</div>
<nav class="flex-1 p-3 space-y-1" id="navList">
${navItems.map(n => `
<button data-page="${n.id}" class="nav-item w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm ${currentPage === n.id ? 'active' : 'text-zinc-400'}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">${n.icon}</svg>
${n.label}
</button>
`).join('')}
</nav>
<div class="p-3 border-t border-zinc-800">
<button onclick="logout()" class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-zinc-400 hover:bg-zinc-900 hover:text-zinc-300 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
退出登录
</button>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 ml-56 p-8" id="mainContent">
<div class="max-w-6xl mx-auto fade-in" id="pageContent"></div>
</main>
</div>`;
}
function bindNav() {
document.querySelectorAll('#navList button').forEach(btn => {
btn.onclick = () => navigate(btn.dataset.page);
});
}
function logout() {
token = '';
localStorage.removeItem('admin_token');
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
render();
}
// ── Pages ──
async function loadPage() {
const el = document.getElementById('pageContent');
try {
switch (currentPage) {
case 'dashboard': await renderDashboard(el); break;
case 'tokens': await renderTokens(el); break;
case 'settings': await renderSettings(el); break;
case 'logs': await renderLogs(el); break;
}
} catch (e) {
el.innerHTML = `<div class="text-red-400">加载失败: ${e.message}</div>`;
}
}
// ── Dashboard ──
async function renderDashboard(el) {
const s = await api('/status');
el.innerHTML = `
<h2 class="text-2xl font-semibold mb-6">Dashboard</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
${statCard('总请求', s.total_requests, 'text-zinc-100')}
${statCard('成功', s.total_success, 'text-emerald-400')}
${statCard('失败', s.total_errors, 'text-red-400')}
${statCard('成功率', s.success_rate + '%', 'text-blue-400')}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-8">
${miniCard('Token 总数', s.total_tokens)}
${miniCard('活跃 Token', s.active_tokens)}
</div>
<div class="rounded-xl border border-zinc-800 bg-zinc-900">
<div class="px-5 py-4 border-b border-zinc-800">
<h3 class="text-sm font-medium">最近请求</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="text-zinc-400 text-left border-b border-zinc-800">
<th class="px-5 py-3 font-medium">时间</th>
<th class="px-5 py-3 font-medium">模型</th>
<th class="px-5 py-3 font-medium">Token</th>
<th class="px-5 py-3 font-medium">状态</th>
<th class="px-5 py-3 font-medium">耗时</th>
</tr></thead>
<tbody>
${s.recent_logs.map(l => `<tr class="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td class="px-5 py-3 text-zinc-400">${fmtTime(l.timestamp)}</td>
<td class="px-5 py-3">${l.model}</td>
<td class="px-5 py-3 text-zinc-400">${l.token_name}</td>
<td class="px-5 py-3">${statusBadge(l.status)}</td>
<td class="px-5 py-3 text-zinc-400">${l.duration}s</td>
</tr>`).join('') || '<tr><td colspan="5" class="px-5 py-8 text-center text-zinc-500">暂无请求记录</td></tr>'}
</tbody>
</table>
</div>
</div>`;
}
function statCard(label, value, colorClass) {
return `
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
<p class="text-sm text-zinc-400 mb-1">${label}</p>
<p class="text-2xl font-semibold ${colorClass}">${value}</p>
</div>`;
}
function miniCard(label, value) {
return `
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-5 flex items-center justify-between">
<span class="text-sm text-zinc-400">${label}</span>
<span class="text-lg font-semibold">${value}</span>
</div>`;
}
// ── Tokens ──
async function renderTokens(el) {
const data = await api('/tokens');
el.innerHTML = `
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-semibold">Tokens</h2>
<div class="flex items-center gap-2">
<button onclick="showGoogleLogin()" class="inline-flex items-center gap-2 rounded-lg border border-zinc-700 px-4 py-2 text-sm hover:bg-zinc-800 transition-colors">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google 登录获取
</button>
<button onclick="showAddToken()" class="inline-flex items-center gap-2 rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
手动添加
</button>
</div>
</div>
<div id="tokenModal"></div>
<div class="rounded-xl border border-zinc-800 bg-zinc-900">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="text-zinc-400 text-left border-b border-zinc-800">
<th class="px-5 py-3 font-medium">状态</th>
<th class="px-5 py-3 font-medium">名称</th>
<th class="px-5 py-3 font-medium">Token</th>
<th class="px-5 py-3 font-medium">请求数</th>
<th class="px-5 py-3 font-medium">错误</th>
<th class="px-5 py-3 font-medium">最后使用</th>
<th class="px-5 py-3 font-medium text-right">操作</th>
</tr></thead>
<tbody>
${data.tokens.map(t => `<tr class="border-b border-zinc-800/50 hover:bg-zinc-800/30" data-id="${t.id}">
<td class="px-5 py-3"><span class="status-dot status-${t.status}"></span></td>
<td class="px-5 py-3 font-medium">${esc(t.name)}</td>
<td class="px-5 py-3 text-zinc-400 font-mono text-xs">${esc(t.value_preview)}</td>
<td class="px-5 py-3">${t.total_requests}</td>
<td class="px-5 py-3 ${t.error_count > 0 ? 'text-red-400' : ''}">${t.error_count}</td>
<td class="px-5 py-3 text-zinc-400">${t.last_used_at ? fmtTime(t.last_used_at) : '-'}</td>
<td class="px-5 py-3 text-right space-x-1">
<button onclick="testToken('${t.id}')" class="inline-flex items-center rounded-md bg-zinc-800 px-2.5 py-1.5 text-xs hover:bg-zinc-700 transition-colors" title="测试">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</button>
<button onclick="toggleToken('${t.id}', ${!t.enabled})" class="inline-flex items-center rounded-md bg-zinc-800 px-2.5 py-1.5 text-xs hover:bg-zinc-700 transition-colors" title="${t.enabled ? '禁用' : '启用'}">
${t.enabled
? '<svg class="w-3.5 h-3.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>'
: '<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"/></svg>'
}
</button>
<button onclick="deleteToken('${t.id}','${esc(t.name)}')" class="inline-flex items-center rounded-md bg-zinc-800 px-2.5 py-1.5 text-xs hover:bg-red-900/50 text-red-400 transition-colors" title="删除">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</td>
</tr>`).join('') || '<tr><td colspan="7" class="px-5 py-8 text-center text-zinc-500">尚未添加 Token</td></tr>'}
</tbody>
</table>
</div>
</div>`;
}
function showAddToken() {
document.getElementById('tokenModal').innerHTML = `
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onclick="if(event.target===this)closeModal()">
<div class="w-full max-w-md rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-2xl fade-in">
<h3 class="text-lg font-semibold mb-4">添加 Token</h3>
<form id="addTokenForm" class="space-y-4">
<div>
<label class="block text-sm text-zinc-400 mb-1.5">名称</label>
<input id="tkName" type="text" placeholder="例如: Account 1" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500">
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1.5">Token 值</label>
<textarea id="tkValue" rows="3" placeholder="JWT|NEXT_AUTH|DEVICE_ID" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-none"></textarea>
<p class="text-xs text-zinc-500 mt-1">格式: JWT 或 JWT|NEXT_AUTH 或 JWT|NEXT_AUTH|DEVICE_ID</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeModal()" class="rounded-lg border border-zinc-700 px-4 py-2 text-sm hover:bg-zinc-800 transition-colors">取消</button>
<button type="submit" class="rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-200 transition-colors">添加</button>
</div>
</form>
</div>
</div>`;
document.getElementById('addTokenForm').onsubmit = async (e) => {
e.preventDefault();
const name = document.getElementById('tkName').value.trim();
const value = document.getElementById('tkValue').value.trim();
if (!name || !value) return;
await api('/tokens', { method: 'POST', body: JSON.stringify({ name, value }) });
closeModal();
renderTokens(document.getElementById('pageContent'));
};
}
function closeModal() {
document.getElementById('tokenModal').innerHTML = '';
}
// ── Google 登录获取 Token ──
const GOOGLE_CLIENT_ID = '448526856882-gks4gsvgspqkcdt8jsql5b5en0mk3v15.apps.googleusercontent.com';
const UPSTREAM_ORIGIN = 'https://web.tabbitbrowser.com';
function showGoogleLogin() {
document.getElementById('tokenModal').innerHTML = `
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onclick="if(event.target===this)closeModal()">
<div class="w-full max-w-lg rounded-xl border border-zinc-800 bg-zinc-900 p-6 shadow-2xl fade-in">
<h3 class="text-lg font-semibold mb-2">Google 登录获取 Token</h3>
<p class="text-sm text-zinc-400 mb-4">通过 Google 账号登录,自动获取可用的 Token。</p>
<div id="glStep1">
<div class="space-y-3">
<div>
<label class="block text-sm text-zinc-400 mb-1.5">Token 名称</label>
<input id="glName" type="text" placeholder="例如: Google Account 1" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500">
</div>
<div class="rounded-lg bg-blue-950/40 border border-blue-900/50 p-3">
<p class="text-xs text-blue-300 font-medium mb-1.5">操作步骤</p>
<ol class="text-xs text-zinc-400 space-y-1 list-decimal list-inside">
<li>点击下方按钮,弹出 Google 登录窗口</li>
<li>在弹窗中 <b class="text-zinc-300">先按 F12</b> 打开开发者工具,切到 <b class="text-zinc-300">Network</b> 标签</li>
<li>选择 Google 账号完成登录</li>
<li>在 Network 中找到 <code class="text-blue-300">transform</code> 请求,点开 → <b class="text-zinc-300">Payload</b> → 复制 <code class="text-blue-300">id_token</code> 的值</li>
<li>粘贴到下方输入框,点提交</li>
</ol>
</div>
<button onclick="openGoogleOAuth()" class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-zinc-100 px-4 py-2.5 text-sm font-medium text-zinc-900 hover:bg-zinc-200 transition-colors">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
打开 Google 登录
</button>
<div>
<label class="block text-sm text-zinc-400 mb-1.5">粘贴 id_token</label>
<textarea id="glManualToken" rows="4" placeholder="eyJhbGciOiJSUzI1NiIs..." class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 font-mono resize-none"></textarea>
</div>
<button onclick="submitGoogleIdToken()" class="w-full rounded-lg bg-emerald-700 px-4 py-2.5 text-sm font-medium text-white hover:bg-emerald-600 transition-colors">提交并获取 Token</button>
</div>
</div>
<div id="glResult" style="display:none"></div>
<div class="flex justify-end pt-4 mt-4 border-t border-zinc-800">
<button onclick="closeModal()" class="rounded-lg border border-zinc-700 px-4 py-2 text-sm hover:bg-zinc-800 transition-colors">关闭</button>
</div>
</div>
</div>`;
}
function openGoogleOAuth() {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
scope: 'openid email profile',
response_type: 'id_token',
redirect_uri: 'gis_transform',
response_mode: 'form_post',
origin: UPSTREAM_ORIGIN,
display: 'popup',
prompt: 'select_account',
gsiwebsdk: 'gis_attributes',
});
window.open(
'https://accounts.google.com/o/oauth2/v2/auth?' + params,
'google_login',
'width=520,height=700,left=200,top=80'
);
}
function submitGoogleIdToken() {
const val = document.getElementById('glManualToken').value.trim();
if (!val) return alert('请先粘贴 id_token');
exchangeGoogleToken(val);
}
async function exchangeGoogleToken(id_token) {
const step1 = document.getElementById('glStep1');
const result = document.getElementById('glResult');
step1.innerHTML = '<div class="flex items-center gap-3 py-6"><svg class="w-5 h-5 animate-spin text-zinc-400" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg><span class="text-sm text-zinc-300">正在换取 Token...</span></div>';
try {
const data = await api('/tokens/google-login', {
method: 'POST',
body: JSON.stringify({ id_token })
});
step1.style.display = 'none';
result.style.display = 'block';
const name = (document.getElementById('glName') ? document.getElementById('glName').value.trim() : '') || 'Google Account';
result.innerHTML = `
<div class="rounded-lg bg-emerald-900/30 border border-emerald-800/50 p-4 mb-3">
<p class="text-sm text-emerald-400 font-medium mb-1">Token 获取成功!</p>
<p class="text-xs text-zinc-400 font-mono break-all">${esc(data.token_value).substring(0, 60)}...</p>
</div>
<button onclick="saveGoogleToken('${esc(name)}', '${esc(data.token_value)}')" class="w-full rounded-lg bg-zinc-100 px-4 py-2.5 text-sm font-medium text-zinc-900 hover:bg-zinc-200 transition-colors">
保存为 "${esc(name)}"
</button>`;
} catch (e) {
step1.style.display = 'none';
result.style.display = 'block';
result.innerHTML = `<div class="rounded-lg bg-red-900/30 border border-red-800/50 p-4"><p class="text-sm text-red-400">${esc(e.message)}</p></div>`;
}
}
async function saveGoogleToken(name, value) {
try {
await api('/tokens', { method: 'POST', body: JSON.stringify({ name, value }) });
closeModal();
renderTokens(document.getElementById('pageContent'));
} catch (e) {
alert('保存失败: ' + e.message);
}
}
async function testToken(id) {
const btn = event.currentTarget;
btn.disabled = true;
btn.innerHTML = '<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>';
try {
const res = await api(`/tokens/${id}/test`, { method: 'POST' });
alert(res.ok ? '连接成功!' : `测试失败: ${res.error}`);
} catch (e) {
alert('测试失败: ' + e.message);
}
renderTokens(document.getElementById('pageContent'));
}
async function toggleToken(id, enabled) {
await api(`/tokens/${id}`, { method: 'PUT', body: JSON.stringify({ enabled }) });
renderTokens(document.getElementById('pageContent'));
}
async function deleteToken(id, name) {
if (!confirm(`确定删除 Token "${name}"?`)) return;
await api(`/tokens/${id}`, { method: 'DELETE' });
renderTokens(document.getElementById('pageContent'));
}
// ── Settings ──
async function renderSettings(el) {
const s = await api('/settings');
el.innerHTML = `
<h2 class="text-2xl font-semibold mb-6">Settings</h2>
<div class="space-y-6">
<!-- Server -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<h3 class="text-sm font-medium text-zinc-300 mb-4">服务器配置</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-zinc-400 mb-1.5">Host</label>
<input id="sHost" type="text" value="${esc(s.server.host)}" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1.5">Port</label>
<input id="sPort" type="number" value="${s.server.port}" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
</div>
</div>
</div>
<!-- Upstream -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<h3 class="text-sm font-medium text-zinc-300 mb-4">上游配置</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-zinc-400 mb-1.5">Base URL</label>
<input id="sBaseUrl" type="text" value="${esc(s.noke.base_url)}" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1.5">Client ID</label>
<input id="sClientId" type="text" value="${esc(s.noke.client_id)}" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
</div>
</div>
</div>
<!-- Proxy -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<h3 class="text-sm font-medium text-zinc-300 mb-4">代理配置</h3>
<div>
<label class="block text-sm text-zinc-400 mb-1.5">API Key(留空则不校验)</label>
<input id="sApiKey" type="text" value="${esc(s.proxy.api_key)}" placeholder="sk-..." class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500">
</div>
</div>
<!-- Claude -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<h3 class="text-sm font-medium text-zinc-300 mb-4">Claude Code 配置</h3>
<div class="max-w-xs mb-4">
<label class="block text-sm text-zinc-400 mb-1.5">默认模型</label>
<select id="sClaudeModel" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
${['best','gpt-5.2-chat','gpt-5.1-chat','gemini-3.1-pro','gemini-3-flash','gemini-2.5-flash','claude-sonnet-4.6','claude-haiku-4.5','glm-5','deepseek-v3.2','minimax-m2.5','kimi-k2.5','qwen3.5-plus','doubao-seed-1.8'].map(m =>
'<option value="' + m + '"' + (s.claude?.default_model === m ? ' selected' : '') + '>' + m + '</option>'
).join('')}
</select>
</div>
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 p-4">
<p class="text-xs text-zinc-400 mb-2 font-medium">Claude Code 连接配置</p>
<code class="block text-xs text-emerald-400 bg-zinc-900 rounded-md p-3 whitespace-pre">ANTHROPIC_BASE_URL=http://localhost:${s.server.port}
ANTHROPIC_API_KEY=${s.proxy.api_key || 'any-key-here'}</code>
<p class="text-xs text-zinc-500 mt-2">支持 /v1/messages 端点,兼容工具调用和 thinking 模式</p>
</div>
</div>
<!-- System Prompt Injection -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<h3 class="text-sm font-medium text-zinc-300 mb-4">提示词注入</h3>
<div class="space-y-4">
<div>
<label class="block text-sm text-zinc-400 mb-1.5">OpenAI 系统提示词 (/v1/chat/completions)</label>
<textarea id="sOpenaiPrompt" rows="3" placeholder="留空则不注入" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-y">${esc(s.proxy.system_prompt || '')}</textarea>
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1.5">Claude 系统提示词 (/v1/messages)</label>
<textarea id="sClaudePrompt" rows="3" placeholder="留空则不注入" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-y">${esc(s.claude?.system_prompt || '')}</textarea>
</div>
</div>
</div>
<!-- Logging -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<h3 class="text-sm font-medium text-zinc-300 mb-4">日志配置</h3>
<div class="max-w-xs">
<label class="block text-sm text-zinc-400 mb-1.5">最大日志条数</label>
<input id="sMaxEntries" type="number" value="${s.logging.max_entries}" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
</div>
</div>
<div class="flex justify-end">
<button onclick="saveSettings()" class="rounded-lg bg-zinc-100 px-6 py-2.5 text-sm font-medium text-zinc-900 hover:bg-zinc-200 transition-colors">保存设置</button>
</div>
<!-- Password -->
<div class="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<h3 class="text-sm font-medium text-zinc-300 mb-4">修改密码</h3>
<div class="space-y-4 max-w-sm">
<div>
<label class="block text-sm text-zinc-400 mb-1.5">当前密码</label>
<input id="pwOld" type="password" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1.5">新密码</label>
<input id="pwNew" type="password" class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
</div>
<button onclick="changePassword()" class="rounded-lg bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm hover:bg-zinc-700 transition-colors">修改密码</button>
</div>
</div>
</div>`;
}
async function saveSettings() {
try {
await api('/settings', {
method: 'PUT',
body: JSON.stringify({
host: document.getElementById('sHost').value,
port: parseInt(document.getElementById('sPort').value),
base_url: document.getElementById('sBaseUrl').value,
client_id: document.getElementById('sClientId').value,
api_key: document.getElementById('sApiKey').value,
claude_default_model: document.getElementById('sClaudeModel').value,
openai_system_prompt: document.getElementById('sOpenaiPrompt').value,
claude_system_prompt: document.getElementById('sClaudePrompt').value,
max_entries: parseInt(document.getElementById('sMaxEntries').value),
})
});
alert('设置已保存(端口变更需重启生效)');
} catch (e) { alert('保存失败: ' + e.message); }
}
async function changePassword() {
const old_password = document.getElementById('pwOld').value;
const new_password = document.getElementById('pwNew').value;
if (!old_password || !new_password) return alert('请填写完整');
try {
await api('/password', { method: 'PUT', body: JSON.stringify({ old_password, new_password }) });
alert('密码已修改');
document.getElementById('pwOld').value = '';
document.getElementById('pwNew').value = '';
} catch (e) { alert('修改失败: ' + e.message); }
}
// ── Logs ──
let logFilter = '';
let logPage = 1;
async function renderLogs(el) {
const params = new URLSearchParams({ page: logPage, page_size: 50 });
if (logFilter) params.set('status', logFilter);
const data = await api('/logs?' + params);
const totalPages = Math.max(1, Math.ceil(data.total / data.page_size));
el.innerHTML = `
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-semibold">Logs</h2>
<div class="flex items-center gap-3">
<select id="logFilterSel" onchange="filterLogs(this.value)" class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100">
<option value="" ${!logFilter ? 'selected' : ''}>全部</option>
<option value="success" ${logFilter === 'success' ? 'selected' : ''}>成功</option>
<option value="error" ${logFilter === 'error' ? 'selected' : ''}>失败</option>
</select>
<label class="flex items-center gap-2 text-sm text-zinc-400">
<input type="checkbox" id="autoRefreshCb" onchange="toggleAutoRefresh(this.checked)" ${autoRefreshTimer ? 'checked' : ''}
class="rounded border-zinc-600 bg-zinc-800 text-zinc-100">
自动刷新
</label>
</div>
</div>
<div class="rounded-xl border border-zinc-800 bg-zinc-900">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="text-zinc-400 text-left border-b border-zinc-800">
<th class="px-5 py-3 font-medium">时间</th>
<th class="px-5 py-3 font-medium">模型</th>
<th class="px-5 py-3 font-medium">Token</th>
<th class="px-5 py-3 font-medium">流式</th>
<th class="px-5 py-3 font-medium">状态</th>
<th class="px-5 py-3 font-medium">耗时</th>
<th class="px-5 py-3 font-medium">错误</th>
</tr></thead>
<tbody>
${data.items.map(l => `<tr class="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td class="px-5 py-3 text-zinc-400 whitespace-nowrap">${fmtTime(l.timestamp)}</td>
<td class="px-5 py-3">${esc(l.model)}</td>
<td class="px-5 py-3 text-zinc-400">${esc(l.token_name)}</td>
<td class="px-5 py-3">${l.stream ? '<span class="text-blue-400">SSE</span>' : 'JSON'}</td>
<td class="px-5 py-3">${statusBadge(l.status)}</td>
<td class="px-5 py-3 text-zinc-400">${l.duration}s</td>
<td class="px-5 py-3 text-red-400 text-xs max-w-xs truncate">${esc(l.error)}</td>
</tr>`).join('') || '<tr><td colspan="7" class="px-5 py-8 text-center text-zinc-500">暂无日志</td></tr>'}
</tbody>
</table>
</div>
${totalPages > 1 ? `
<div class="flex items-center justify-between px-5 py-3 border-t border-zinc-800">
<span class="text-sm text-zinc-400">共 ${data.total} 条</span>
<div class="flex items-center gap-2">
<button onclick="gotoLogPage(${logPage - 1})" ${logPage <= 1 ? 'disabled' : ''} class="rounded-md bg-zinc-800 px-3 py-1.5 text-xs hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">上一页</button>
<span class="text-sm text-zinc-400">${logPage} / ${totalPages}</span>
<button onclick="gotoLogPage(${logPage + 1})" ${logPage >= totalPages ? 'disabled' : ''} class="rounded-md bg-zinc-800 px-3 py-1.5 text-xs hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">下一页</button>
</div>
</div>` : ''}
</div>`;
}
function filterLogs(val) { logFilter = val; logPage = 1; renderLogs(document.getElementById('pageContent')); }
function gotoLogPage(p) { logPage = p; renderLogs(document.getElementById('pageContent')); }
function toggleAutoRefresh(on) {
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
if (on) {
autoRefreshTimer = setInterval(() => {
if (currentPage === 'logs') renderLogs(document.getElementById('pageContent'));
}, 5000);
}
}
// ── Helpers ──
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fmtTime(ts) {
if (!ts) return '-';
try {
const d = new Date(ts);
return d.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return ts; }
}
function statusBadge(s) {
const colors = { success: 'bg-emerald-900/50 text-emerald-400', error: 'bg-red-900/50 text-red-400', pending: 'bg-zinc-800 text-zinc-400' };
return `<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colors[s] || colors.pending}">${s}</span>`;
}
// ── Init ──
render();
</script>
</body>
</html>