Files
tavily-key-generator/proxy/templates/console.html
T
2026-03-16 01:23:43 +08:00

384 lines
18 KiB
HTML
Raw 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.
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tavily API Proxy</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; color: #1a1a2e; min-height: 100vh; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
.header-left h1 { font-size: 24px; margin-bottom: 4px; }
.header-left .subtitle { color: #666; font-size: 14px; }
.user-menu { position: relative; }
.user-btn { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #fff; border: 1px solid #ddd; border-radius: 8px; cursor: pointer; font-size: 14px; }
.user-btn:hover { background: #f5f5f5; }
.user-btn svg { width: 20px; height: 20px; }
.dropdown { display: none; position: absolute; right: 0; top: 44px; background: #fff; border: 1px solid #e0e0e0; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.12); width: 280px; z-index: 100; padding: 16px; }
.dropdown.show { display: block; }
.dropdown h3 { font-size: 14px; margin-bottom: 12px; color: #333; }
.dropdown .pwd-row { display: flex; gap: 8px; margin-bottom: 12px; }
.dropdown .pwd-row input { flex: 1; padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.dropdown .pwd-row button { white-space: nowrap; }
.dropdown-divider { height: 1px; background: #eee; margin: 12px 0; }
.dropdown .logout-btn { display: block; width: 100%; text-align: center; padding: 8px; color: #e74c3c; border: 1px solid #e74c3c; border-radius: 6px; cursor: pointer; font-size: 13px; background: #fff; }
.dropdown .logout-btn:hover { background: #fdf0ef; }
.card { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.card h2 { font-size: 18px; margin-bottom: 6px; }
.card .desc { color: #888; font-size: 13px; margin-bottom: 16px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
.stat-box { border: 1px solid #e8e8e8; border-radius: 8px; padding: 14px; }
.stat-box .label { font-size: 13px; color: #888; margin-bottom: 4px; }
.stat-box .value { font-size: 28px; font-weight: 700; }
.stat-box .value span { font-size: 16px; color: #888; font-weight: 400; }
.stat-box .remain { font-size: 13px; color: #1a8a1a; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; padding: 10px 12px; color: #888; font-weight: 500; border-bottom: 1px solid #eee; }
td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; }
.btn { display: inline-block; padding: 6px 16px; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer; font-size: 13px; }
.btn:hover { background: #f5f5f5; }
.btn-primary { background: #1a1a6e; color: #fff; border-color: #1a1a6e; }
.btn-primary:hover { background: #2a2a8e; }
.btn-danger { color: #e74c3c; border-color: #e74c3c; }
.btn-danger:hover { background: #fdf0ef; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
input[type="text"], input[type="password"], textarea { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
textarea { min-height: 80px; resize: vertical; }
.flex { display: flex; gap: 8px; align-items: center; }
.mt { margin-top: 12px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.tag-ok { background: #e8f5e9; color: #2e7d32; }
.tag-off { background: #fce4ec; color: #c62828; }
.mono { font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 13px; }
.quota-bar { height: 4px; background: #e8e8e8; border-radius: 2px; margin-top: 3px; }
.quota-bar-fill { height: 100%; border-radius: 2px; background: #1a8a1a; transition: width .3s; }
.quota-bar-fill.warn { background: #f39c12; }
.quota-bar-fill.danger { background: #e74c3c; }
#login-box { max-width: 360px; margin: 120px auto; }
.hidden { display: none; }
.code-block { background: #1a1a2e; color: #e0e0e0; border-radius: 8px; padding: 16px; font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 13px; line-height: 1.6; overflow-x: auto; position: relative; }
.code-block .comment { color: #6a9955; }
.code-block .string { color: #ce9178; }
.code-block .key { color: #9cdcfe; }
.copy-code-btn { position: absolute; top: 8px; right: 8px; padding: 4px 10px; font-size: 11px; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.2); color: #ccc; border-radius: 4px; cursor: pointer; }
.copy-code-btn:hover { background: rgba(255,255,255,.2); }
</style>
</head>
<body>
<!-- Login -->
<div id="login-box" class="card">
<h2>Tavily API Proxy</h2>
<p class="desc">输入管理密码进入控制台</p>
<input type="password" id="pwd-input" placeholder="密码" onkeydown="if(event.key==='Enter')doLogin()">
<button class="btn btn-primary mt" style="width:100%" onclick="doLogin()">登录</button>
<p id="login-err" style="color:#e74c3c;font-size:13px;margin-top:8px" class="hidden"></p>
</div>
<!-- Dashboard -->
<div id="dashboard" class="hidden">
<div class="container">
<div class="header">
<div class="header-left">
<h1>控制台</h1>
<p class="subtitle">Tavily API Proxy · Key 池管理</p>
</div>
<div class="user-menu">
<div class="user-btn" onclick="toggleDropdown()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0112 0v1"/></svg>
Admin
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><path d="M6 9l6 6 6-6"/></svg>
</div>
<div class="dropdown" id="user-dropdown">
<h3>修改密码</h3>
<div class="pwd-row">
<input type="password" id="new-pwd" placeholder="输入新密码">
<button class="btn btn-primary btn-sm" onclick="changePwd()">确认</button>
</div>
<div class="dropdown-divider"></div>
<button class="logout-btn" onclick="logout()">退出登录</button>
</div>
</div>
</div>
<!-- Overview -->
<div class="card">
<h2>用量概览</h2>
<p class="desc">账户级成功率、失败量与总余额</p>
<div class="stats-grid" id="overview-stats"></div>
</div>
<!-- API Usage -->
<div class="card">
<h2>调用方式</h2>
<p class="desc">兼容 Tavily 官方 API,替换 base URL 和 api_key 即可</p>
<div style="margin-bottom:12px">
<div style="font-size:13px;color:#666;margin-bottom:6px">API 地址</div>
<div class="mono" style="font-size:14px;padding:8px 12px;background:#f5f5f5;border-radius:6px;display:inline-block" id="api-base-url"></div>
</div>
<div style="font-size:13px;color:#666;margin-bottom:6px">调用示例 (curl)</div>
<div class="code-block" id="curl-example">
<button class="copy-code-btn" onclick="copyCurl()">复制</button>
</div>
</div>
<!-- Tokens -->
<div class="card">
<h2>Token 列表</h2>
<p class="desc">代理访问 Token,分配给应用使用</p>
<div class="flex mt" style="margin-bottom:12px">
<input type="text" id="token-name" placeholder="Token 备注名(可选)" style="max-width:240px">
<button class="btn btn-primary" onclick="createToken()">创建 Token</button>
</div>
<div style="overflow-x:auto">
<table id="tokens-table"><thead><tr><th>Token</th><th>备注</th><th>配额 / 余额</th><th>用量统计</th><th>操作</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Keys -->
<div class="card">
<h2>API Key 池</h2>
<p class="desc">管理 Tavily API Key,支持批量导入</p>
<div class="flex mt" style="margin-bottom:12px;flex-wrap:wrap">
<input type="text" id="single-key" placeholder="tvly-xxx" style="max-width:360px">
<button class="btn btn-primary" onclick="addSingleKey()">添加</button>
<button class="btn" onclick="document.getElementById('import-area').classList.toggle('hidden')">批量导入</button>
</div>
<div id="import-area" class="hidden" style="margin-bottom:12px">
<textarea id="import-text" placeholder="粘贴 api_keys.md 内容,每行一条&#10;格式: email,password,tvly-xxx,timestamp;"></textarea>
<button class="btn btn-primary mt" onclick="importKeys()">导入</button>
</div>
<div style="overflow-x:auto">
<table id="keys-table"><thead><tr><th>ID</th><th>Key</th><th>邮箱</th><th>成功</th><th>失败</th><th>状态</th><th>操作</th></tr></thead><tbody></tbody></table>
</div>
</div>
</div>
</div>
<script>
const API = '';
let PWD = localStorage.getItem('tavily_proxy_pwd') || '';
function headers() {
return { 'Content-Type': 'application/json', 'X-Admin-Password': PWD };
}
async function api(method, path, body) {
const opts = { method, headers: headers() };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(API + path, opts);
if (r.status === 401) { logout(); throw new Error('Unauthorized'); }
return r.json();
}
function doLogin() {
PWD = document.getElementById('pwd-input').value;
localStorage.setItem('tavily_proxy_pwd', PWD);
api('GET', '/api/stats').then(() => {
document.getElementById('login-box').classList.add('hidden');
document.getElementById('dashboard').classList.remove('hidden');
refresh();
}).catch(() => {
document.getElementById('login-err').textContent = '密码错误';
document.getElementById('login-err').classList.remove('hidden');
});
}
function logout() {
PWD = '';
localStorage.removeItem('tavily_proxy_pwd');
document.getElementById('login-box').classList.remove('hidden');
document.getElementById('dashboard').classList.add('hidden');
document.getElementById('user-dropdown').classList.remove('show');
}
function toggleDropdown() {
document.getElementById('user-dropdown').classList.toggle('show');
}
// Close dropdown on outside click
document.addEventListener('click', e => {
const menu = document.querySelector('.user-menu');
if (menu && !menu.contains(e.target)) {
document.getElementById('user-dropdown').classList.remove('show');
}
});
function quotaBar(used, limit) {
const pct = limit ? Math.min(100, (used / limit) * 100) : 0;
const cls = pct >= 90 ? 'danger' : pct >= 70 ? 'warn' : '';
return `<div class="quota-bar"><div class="quota-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
}
async function refresh() {
const stats = await api('GET', '/api/stats');
const o = stats.overview;
// 每个 Tavily 免费 key 月限额 1000 次
const KEY_MONTHLY_QUOTA = 1000;
const totalQuota = stats.keys_active * KEY_MONTHLY_QUOTA;
const monthUsed = o.month_count;
const monthRemain = Math.max(0, totalQuota - monthUsed);
const usedPct = totalQuota ? Math.min(100, (monthUsed / totalQuota) * 100) : 0;
const remainCls = usedPct >= 90 ? 'color:#e74c3c' : usedPct >= 70 ? 'color:#f39c12' : 'color:#1a8a1a';
document.getElementById('overview-stats').innerHTML = `
<div class="stat-box"><div class="label">本月总额度</div><div class="value">${totalQuota.toLocaleString()}</div><div class="remain">Key ${stats.keys_active} × ${KEY_MONTHLY_QUOTA}</div></div>
<div class="stat-box"><div class="label">已用次数</div><div class="value">${monthUsed}</div><div class="remain">成功 ${o.month_success} / 失败 ${monthUsed - o.month_success}</div></div>
<div class="stat-box"><div class="label">剩余次数</div><div class="value" style="${remainCls}">${monthRemain.toLocaleString()}</div>${quotaBar(monthUsed, totalQuota)}</div>
<div class="stat-box"><div class="label">今日用量</div><div class="value">${o.today_count}</div><div class="remain">成功 ${o.today_success} / 失败 ${o.today_failed}</div></div>
<div class="stat-box"><div class="label">Key 池</div><div class="value">${stats.keys_active} <span>/ ${stats.keys_total}</span></div></div>
<div class="stat-box"><div class="label">Token 数</div><div class="value">${stats.tokens.length}</div></div>
`;
// API usage info
const baseUrl = location.origin;
document.getElementById('api-base-url').textContent = baseUrl;
const firstToken = stats.tokens.length > 0 ? stats.tokens[0].token : 'YOUR_TOKEN';
document.getElementById('curl-example').innerHTML = `<button class="copy-code-btn" onclick="copyCurl()">复制</button><span class="comment"># Search 搜索</span>
curl -X POST <span class="string">${baseUrl}/api/search</span> \\
-H <span class="string">"Content-Type: application/json"</span> \\
-H <span class="string">"Authorization: Bearer <span class="key">${firstToken}</span>"</span> \\
-d <span class="string">'{"query": "your search query"}'</span>
<span class="comment"># Extract 内容提取</span>
curl -X POST <span class="string">${baseUrl}/api/extract</span> \\
-H <span class="string">"Content-Type: application/json"</span> \\
-H <span class="string">"Authorization: Bearer <span class="key">${firstToken}</span>"</span> \\
-d <span class="string">'{"urls": ["https://example.com"]}'</span>
<span class="comment"># 或在 body 中传 api_key</span>
curl -X POST <span class="string">${baseUrl}/api/search</span> \\
-H <span class="string">"Content-Type: application/json"</span> \\
-d <span class="string">'{"api_key": "<span class="key">${firstToken}</span>", "query": "your search query"}'</span>`;
// Tokens
const tokensData = await api('GET', '/api/tokens');
const tbody = document.querySelector('#tokens-table tbody');
tbody.innerHTML = '';
for (const t of tokensData.tokens) {
const s = t.stats || {};
const unlimited = !t.hourly_limit && !t.daily_limit && !t.monthly_limit;
let quotaHtml;
if (unlimited) {
quotaHtml = '<span style="color:#1a8a1a;font-weight:600">无限制</span>';
} else {
const hr = t.hourly_limit - (s.hour_count||0);
const dr = t.daily_limit - (s.today_count||0);
const mr = t.monthly_limit - (s.month_count||0);
quotaHtml = `
<div>小时 ${s.hour_count||0}/${t.hourly_limit} <b style="color:${hr>0?'#1a8a1a':'#e74c3c'}">余 ${hr}</b>${quotaBar(s.hour_count||0, t.hourly_limit)}</div>
<div>今日 ${s.today_count||0}/${t.daily_limit} <b style="color:${dr>0?'#1a8a1a':'#e74c3c'}">余 ${dr}</b>${quotaBar(s.today_count||0, t.daily_limit)}</div>
<div>本月 ${s.month_count||0}/${t.monthly_limit} <b style="color:${mr>0?'#1a8a1a':'#e74c3c'}">余 ${mr}</b>${quotaBar(s.month_count||0, t.monthly_limit)}</div>`;
}
tbody.innerHTML += `<tr>
<td class="mono">${t.token}</td>
<td style="font-size:12px">${t.name||'-'}</td>
<td style="font-size:12px;line-height:2">${quotaHtml}</td>
<td style="font-size:12px;line-height:1.8">
成功 ${s.today_success||0} / 失败 ${s.today_failed||0}<br>
本月 ${s.month_success||0}
</td>
<td>
<button class="btn btn-sm" onclick="copyText('${t.token}')">复制</button>
<button class="btn btn-sm btn-danger" onclick="delToken(${t.id})">删除</button>
</td>
</tr>`;
}
// Keys
const keysData = await api('GET', '/api/keys');
const ktbody = document.querySelector('#keys-table tbody');
ktbody.innerHTML = '';
for (const k of keysData.keys) {
const active = k.active === 1;
ktbody.innerHTML += `<tr>
<td>${k.id}</td>
<td class="mono">${k.key_masked}</td>
<td style="font-size:12px">${k.email||'-'}</td>
<td>${k.total_used}</td>
<td>${k.total_failed}</td>
<td><span class="tag ${active?'tag-ok':'tag-off'}">${active?'正常':'禁用'}</span></td>
<td>
<button class="btn btn-sm" onclick="toggleKey(${k.id},${active?0:1})">${active?'禁用':'启用'}</button>
<button class="btn btn-sm btn-danger" onclick="delKey(${k.id})">删除</button>
</td>
</tr>`;
}
}
async function createToken() {
const name = document.getElementById('token-name').value;
await api('POST', '/api/tokens', { name });
document.getElementById('token-name').value = '';
refresh();
}
async function delToken(id) { if(confirm('确认删除?')){ await api('DELETE', `/api/tokens/${id}`); refresh(); } }
async function addSingleKey() {
const key = document.getElementById('single-key').value.trim();
if (!key) return;
await api('POST', '/api/keys', { key });
document.getElementById('single-key').value = '';
refresh();
}
async function importKeys() {
const text = document.getElementById('import-text').value;
if (!text.trim()) return;
const r = await api('POST', '/api/keys', { file: text });
alert(`已导入 ${r.imported} 个 Key`);
document.getElementById('import-text').value = '';
document.getElementById('import-area').classList.add('hidden');
refresh();
}
async function delKey(id) { if(confirm('确认删除?')){ await api('DELETE', `/api/keys/${id}`); refresh(); } }
async function toggleKey(id, active) { await api('PUT', `/api/keys/${id}/toggle`, { active }); refresh(); }
async function changePwd() {
const newPwd = document.getElementById('new-pwd').value.trim();
if (!newPwd || newPwd.length < 4) { alert('密码至少 4 位'); return; }
try {
await api('PUT', '/api/password', { password: newPwd });
PWD = newPwd;
localStorage.setItem('tavily_proxy_pwd', PWD);
document.getElementById('new-pwd').value = '';
document.getElementById('user-dropdown').classList.remove('show');
alert('密码已修改');
} catch(e) { alert('修改失败: ' + e.message); }
}
function copyCurl() {
const el = document.getElementById('curl-example');
const text = el.innerText.replace('复制', '').trim();
navigator.clipboard.writeText(text).then(() => {
const btn = el.querySelector('.copy-code-btn');
btn.textContent = '已复制';
setTimeout(() => btn.textContent = '复制', 1500);
});
}
function copyText(t) {
navigator.clipboard.writeText(t).then(() => {
const el = event.target;
el.textContent = '已复制';
setTimeout(() => el.textContent = '复制', 1500);
});
}
// Auto login
if (PWD) {
api('GET', '/api/stats').then(() => {
document.getElementById('login-box').classList.add('hidden');
document.getElementById('dashboard').classList.remove('hidden');
refresh();
}).catch(() => {});
}
</script>
</body>
</html>