ikun2 / ui.js
bingn's picture
Upload 19 files
99f8658 verified
/**
* ui.js - Dashboard 控制台页面
*
* 内嵌完整的 CSS + JavaScript,无外部依赖
* 通过 GET / 或 GET /ui 访问
*/
export function getDashboardHTML() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChatAIBot API Proxy</title>
<style>
:root {
--bg: #f0f2f5;
--bg-card: #ffffff;
--text: #1a1a2e;
--text-sec: #6b7280;
--border: #e5e7eb;
--radius: 12px;
--shadow: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
--shadow-md: 0 4px 12px rgba(0,0,0,.08);
--c-active: #10b981;
--c-active-bg: #ecfdf5;
--c-inuse: #3b82f6;
--c-inuse-bg: #eff6ff;
--c-login: #f59e0b;
--c-login-bg: #fffbeb;
--c-exhaust: #9ca3af;
--c-exhaust-bg: #f3f4f6;
--c-dead: #ef4444;
--c-dead-bg: #fef2f2;
--c-total: #8b5cf6;
--c-total-bg: #f5f3ff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.header { display: flex; justify-content: space-between; align-items: center; padding: 16px 28px; background: var(--bg-card); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
.logo { font-size: 1.2rem; font-weight: 700; letter-spacing: -.5px; }
.logo-sub { font-weight: 400; color: var(--text-sec); margin-left: 6px; font-size: .85rem; }
.header-right { display: flex; align-items: center; gap: 16px; font-size: .85rem; color: var(--text-sec); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c-active); display: inline-block; }
.dot.loading { animation: pulse .6s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.3 } }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
.stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 16px; margin-bottom: 28px; }
.card { background: var(--bg-card); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow); border-left: 4px solid transparent; text-align: center; transition: transform .15s, box-shadow .15s; cursor: default; }
.card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.card-total { border-left-color: var(--c-total); }
.card-total .card-val { color: var(--c-total); }
.card-active { border-left-color: var(--c-active); }
.card-active .card-val { color: var(--c-active); }
.card-inuse { border-left-color: var(--c-inuse); }
.card-inuse .card-val { color: var(--c-inuse); }
.card-login { border-left-color: var(--c-login); }
.card-login .card-val { color: var(--c-login); }
.card-exhaust { border-left-color: var(--c-exhaust); }
.card-exhaust .card-val { color: var(--c-exhaust); }
.card-dead { border-left-color: var(--c-dead); }
.card-dead .card-val { color: var(--c-dead); }
.card-val { font-size: 2rem; font-weight: 700; line-height: 1; }
.card-label { font-size: .82rem; color: var(--text-sec); margin-top: 8px; }
.section { background: var(--bg-card); border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 24px; overflow: hidden; }
.section-head { padding: 18px 24px; font-size: .95rem; font-weight: 600; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.model-groups { padding: 20px 24px; }
.model-group { margin-bottom: 18px; }
.model-group:last-child { margin-bottom: 0; }
.mg-title { font-size: .85rem; font-weight: 600; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.mg-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.mg-count { font-weight: 400; color: var(--text-sec); font-size: .78rem; }
.mg-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { display: inline-block; padding: 4px 14px; border-radius: 20px; font-size: .78rem; font-family: "SF Mono", "Cascadia Code", Consolas, monospace; background: #f8f9fa; border: 1px solid var(--border); transition: background .15s; cursor: default; }
.chip:hover { background: #eef0f2; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { background: #f9fafb; text-align: left; padding: 12px 20px; font-weight: 600; font-size: .82rem; color: var(--text-sec); border-bottom: 2px solid var(--border); white-space: nowrap; }
td { padding: 11px 20px; border-bottom: 1px solid var(--border); font-size: .88rem; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafbfc; }
.email { font-family: monospace; font-size: .82rem; }
.badge { display: inline-block; padding: 2px 12px; border-radius: 12px; font-size: .75rem; font-weight: 500; }
.badge-active { background: var(--c-active-bg); color: var(--c-active); }
.badge-in_use { background: var(--c-inuse-bg); color: var(--c-inuse); }
.badge-needs_login { background: var(--c-login-bg); color: var(--c-login); }
.badge-exhausted { background: var(--c-exhaust-bg); color: var(--c-exhaust); }
.badge-dead { background: var(--c-dead-bg); color: var(--c-dead); }
.empty { text-align: center; padding: 40px; color: var(--text-sec); font-size: .9rem; }
/* Register bar */
.reg-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.reg-label { font-size: .82rem; font-weight: 400; color: var(--text-sec); }
.reg-input { width: 60px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: .85rem; text-align: center; outline: none; }
.reg-input:focus { border-color: var(--c-inuse); }
.reg-select { padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: .82rem; outline: none; background: var(--bg-card); color: var(--text); cursor: pointer; max-width: 140px; }
.reg-select:focus { border-color: var(--c-inuse); }
.btn { padding: 6px 16px; border: none; border-radius: 6px; font-size: .82rem; font-weight: 500; cursor: pointer; transition: background .15s, opacity .15s; }
.btn-primary { background: var(--c-inuse); color: #fff; }
.btn-primary:hover { background: #2563eb; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.reg-msg { font-size: .78rem; color: var(--text-sec); }
.reg-msg.ok { color: var(--c-active); }
.reg-msg.err { color: var(--c-dead); }
/* Log panel */
.log-box { max-height: 260px; overflow-y: auto; padding: 14px 20px; background: #1a1a2e; font-family: "SF Mono", "Cascadia Code", Consolas, monospace; font-size: .75rem; line-height: 1.7; }
.log-line { color: #94a3b8; }
.log-line .log-time { color: #64748b; margin-right: 8px; }
.log-line.log-success { color: #34d399; }
.log-line.log-error { color: #f87171; }
.log-line.log-warn { color: #fbbf24; }
.log-empty { color: #475569; text-align: center; padding: 20px 0; }
/* Pagination */
.pager { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; border-top: 1px solid var(--border); font-size: .82rem; color: var(--text-sec); }
.pager-btns { display: flex; gap: 4px; }
.pager-btn { padding: 4px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-card); cursor: pointer; font-size: .78rem; color: var(--text); transition: background .15s; }
.pager-btn:hover { background: #f0f2f5; }
.pager-btn.active { background: var(--c-inuse); color: #fff; border-color: var(--c-inuse); }
.pager-btn:disabled { opacity: .4; cursor: not-allowed; }
/* Guide */
.guide { padding: 20px 24px; }
.guide-title { font-size: .88rem; font-weight: 600; margin-bottom: 12px; }
.guide-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.guide-item { padding: 16px; background: #f8f9fa; border-radius: 8px; border: 1px solid var(--border); }
.guide-item h4 { font-size: .82rem; font-weight: 600; margin-bottom: 8px; color: var(--text); }
.guide-item p { font-size: .78rem; color: var(--text-sec); margin-bottom: 8px; line-height: 1.5; }
.guide-item code { display: block; padding: 10px 14px; background: #1a1a2e; color: #e2e8f0; border-radius: 6px; font-size: .75rem; font-family: "SF Mono", "Cascadia Code", Consolas, monospace; overflow-x: auto; white-space: pre; line-height: 1.6; }
@media (max-width: 900px) { .stats { grid-template-columns: repeat(3, 1fr); } .guide-grid { grid-template-columns: 1fr; } }
@media (max-width: 500px) { .stats { grid-template-columns: repeat(2, 1fr); } .container { padding: 16px; } .reg-bar { flex-wrap: wrap; } }
</style>
</head>
<body>
<header class="header">
<div><span class="logo">ChatAIBot<span class="logo-sub">API Proxy</span></span></div>
<div class="header-right">
<span id="uptime">\u8FD0\u884C\u65F6\u95F4: --</span>
<span class="dot" id="dot"></span>
</div>
</header>
<div class="container">
<div class="stats">
<div class="card card-total"><div class="card-val" id="s-total">--</div><div class="card-label">\u603B\u8BA1\u8D26\u53F7</div></div>
<div class="card card-active"><div class="card-val" id="s-active">--</div><div class="card-label">\u53EF\u7528</div></div>
<div class="card card-inuse"><div class="card-val" id="s-inuse">--</div><div class="card-label">\u4F7F\u7528\u4E2D</div></div>
<div class="card card-login"><div class="card-val" id="s-login">--</div><div class="card-label">\u9700\u767B\u5F55</div></div>
<div class="card card-exhaust"><div class="card-val" id="s-exhaust">--</div><div class="card-label">\u5DF2\u8017\u5C3D</div></div>
<div class="card card-dead"><div class="card-val" id="s-dead">--</div><div class="card-label">\u5DF2\u5931\u6548</div></div>
</div>
<!-- Register + Logs -->
<div class="section">
<div class="section-head">
<span>\u8D26\u53F7\u7BA1\u7406</span>
<div class="reg-bar">
<span class="reg-label">\u90AE\u7BB1</span>
<select class="reg-select" id="regProvider">
<option value="">auto (\u914D\u7F6E\u9ED8\u8BA4)</option>
<option value="mailtm">Mail.tm</option>
<option value="guerrilla">Guerrilla</option>
<option value="tempmail">TempMail</option>
<option value="tempmailio">TempMail.io</option>
<option value="dropmail">Dropmail</option>
<option value="linshiyou">linshiyou</option>
<option value="gptmail">GPTMail</option>
<option value="moemail">MoeMail</option>
<option value="duckmail">DuckMail</option>
<option value="catchall">CatchAll</option>
</select>
<span class="reg-label">\u6570\u91CF</span>
<input type="number" class="reg-input" id="regCount" value="5" min="1" max="50">
<span class="reg-label">\u5E76\u53D1</span>
<input type="number" class="reg-input" id="regConcurrency" value="5" min="1" max="10">
<button class="btn btn-primary" id="regBtn" onclick="doRegister()">\u624B\u52A8\u6CE8\u518C</button>
<span class="reg-msg" id="regMsg"></span>
</div>
</div>
<div class="log-box" id="logBox"><div class="log-empty">\u6682\u65E0\u65E5\u5FD7</div></div>
</div>
<div class="section">
<div class="section-head">\u53EF\u7528\u6A21\u578B</div>
<div class="model-groups" id="modelGroups"><div class="empty">\u52A0\u8F7D\u4E2D...</div></div>
</div>
<div class="section">
<div class="section-head">\u8D26\u53F7\u8BE6\u60C5</div>
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>\u90AE\u7BB1</th><th>\u72B6\u6001</th><th>\u5269\u4F59\u989D\u5EA6</th><th>\u6700\u540E\u4F7F\u7528</th></tr></thead>
<tbody id="tbody"><tr><td colspan="5" class="empty">\u52A0\u8F7D\u4E2D...</td></tr></tbody>
</table>
</div>
<div class="pager" id="pager" style="display:none">
<span class="pager-info" id="pagerInfo"></span>
<div class="pager-btns" id="pagerBtns"></div>
</div>
</div>
<!-- Usage Guide -->
<div class="section">
<div class="section-head">\u4F7F\u7528\u65B9\u6CD5</div>
<div class="guide">
<div class="guide-grid">
<div class="guide-item">
<h4>OpenAI \u683C\u5F0F (\u975E\u6D41\u5F0F)</h4>
<p>\u517C\u5BB9 OpenAI SDK / ChatBox / Cherry Studio \u7B49\u5BA2\u6237\u7AEF</p>
<code>curl http://localhost:9090/v1/chat/completions \\
-H "Content-Type: application/json" \\
-d '{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "Hello"}
]
}'</code>
</div>
<div class="guide-item">
<h4>OpenAI \u683C\u5F0F (\u6D41\u5F0F)</h4>
<p>\u6DFB\u52A0 stream: true \u5F00\u542F SSE \u6D41\u5F0F\u8F93\u51FA</p>
<code>curl http://localhost:9090/v1/chat/completions \\
-H "Content-Type: application/json" \\
-d '{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "Hello"}
],
"stream": true
}'</code>
</div>
<div class="guide-item">
<h4>Anthropic \u683C\u5F0F</h4>
<p>\u517C\u5BB9 Anthropic SDK \u7684 Messages API</p>
<code>curl http://localhost:9090/v1/messages \\
-H "Content-Type: application/json" \\
-d '{
"model": "claude-4.6-sonnet",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hello"}
]
}'</code>
</div>
<div class="guide-item">
<h4>\u5BA2\u6237\u7AEF\u914D\u7F6E</h4>
<p>\u5728 Cherry Studio / ChatBox \u7B49\u5BA2\u6237\u7AEF\u4E2D\u914D\u7F6E\uFF1A</p>
<code>API \u5730\u5740: http://localhost:9090
API Key: \u7559\u7A7A (\u65E0\u9700\u8BA4\u8BC1)
\u6A21\u578B: \u70B9\u51FB\u201C\u83B7\u53D6\u6A21\u578B\u5217\u8868\u201D\u81EA\u52A8\u62C9\u53D6
\u652F\u6301\u7684\u63A5\u53E3:
OpenAI: POST /v1/chat/completions
Anthropic: POST /v1/messages
\u6A21\u578B\u5217\u8868: GET /v1/models</code>
</div>
</div>
</div>
</div>
</div>
<script>
var STATES = { active: "\u53EF\u7528", in_use: "\u4F7F\u7528\u4E2D", needs_login: "\u9700\u767B\u5F55", exhausted: "\u5DF2\u8017\u5C3D", dead: "\u5DF2\u5931\u6548" };
var GCOLORS = { OpenAI: "#10a37f", Anthropic: "#d97706", Google: "#4285f4", "\u5176\u4ED6": "#6366f1" };
var GORDER = ["OpenAI", "Anthropic", "Google", "\u5176\u4ED6"];
var PAGE_SIZE = 20;
var currentPage = 1;
var allAccounts = [];
var logSince = 0;
function fmt(s) {
s = Math.floor(s);
var d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60), sec = s%60;
var t = "";
if (d) t += d + "\u5929 ";
if (h || d) t += h + "\u65F6 ";
t += m + "\u5206 " + sec + "\u79D2";
return t;
}
function ago(iso) {
if (!iso) return "\u4ECE\u672A\u4F7F\u7528";
var ms = Date.now() - new Date(iso).getTime();
if (ms < 60000) return "\u521A\u521A";
if (ms < 3600000) return Math.floor(ms/60000) + "\u5206\u949F\u524D";
if (ms < 86400000) return Math.floor(ms/3600000) + "\u5C0F\u65F6\u524D";
return new Date(iso).toLocaleString("zh-CN", { month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit" });
}
function mask(e) {
var p = e.split("@");
if (p[0].length <= 4) return e;
return p[0].substring(0,4) + "***@" + p[1];
}
function fmtLogTime(iso) {
return new Date(iso).toLocaleString("zh-CN", { hour:"2-digit", minute:"2-digit", second:"2-digit", hour12:false });
}
function esc(s) {
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function renderPage() {
var total = allAccounts.length;
var totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (currentPage > totalPages) currentPage = totalPages;
var start = (currentPage - 1) * PAGE_SIZE;
var pageItems = allAccounts.slice(start, start + PAGE_SIZE);
var html = "";
for (var i = 0; i < pageItems.length; i++) {
var a = pageItems[i];
html += "<tr><td>" + (start + i + 1) + '</td><td class="email" title="' + a.email + '">' + mask(a.email) + '</td><td><span class="badge badge-' + a.state + '">' + (STATES[a.state]||a.state) + "</span></td><td>" + (a.remaining != null ? a.remaining : "--") + "</td><td>" + ago(a.lastUsed) + "</td></tr>";
}
document.getElementById("tbody").innerHTML = html || '<tr><td colspan="5" class="empty">\u6682\u65E0\u8D26\u53F7</td></tr>';
var pager = document.getElementById("pager");
if (total <= PAGE_SIZE) {
pager.style.display = "none";
return;
}
pager.style.display = "flex";
document.getElementById("pagerInfo").textContent = "\u5171 " + total + " \u4E2A\u8D26\u53F7\uFF0C\u7B2C " + currentPage + "/" + totalPages + " \u9875";
var btns = "";
btns += '<button class="pager-btn" onclick="goPage(' + (currentPage-1) + ')"' + (currentPage <= 1 ? " disabled" : "") + '>\u4E0A\u4E00\u9875</button>';
var lo = Math.max(1, currentPage - 2);
var hi = Math.min(totalPages, currentPage + 2);
if (lo > 1) btns += '<button class="pager-btn" onclick="goPage(1)">1</button>';
if (lo > 2) btns += '<span style="padding:4px 6px;color:var(--text-sec)">...</span>';
for (var pg = lo; pg <= hi; pg++) {
btns += '<button class="pager-btn' + (pg === currentPage ? " active" : "") + '" onclick="goPage(' + pg + ')">' + pg + '</button>';
}
if (hi < totalPages - 1) btns += '<span style="padding:4px 6px;color:var(--text-sec)">...</span>';
if (hi < totalPages) btns += '<button class="pager-btn" onclick="goPage(' + totalPages + ')">' + totalPages + '</button>';
btns += '<button class="pager-btn" onclick="goPage(' + (currentPage+1) + ')"' + (currentPage >= totalPages ? " disabled" : "") + '>\u4E0B\u4E00\u9875</button>';
document.getElementById("pagerBtns").innerHTML = btns;
}
function goPage(p) {
var totalPages = Math.max(1, Math.ceil(allAccounts.length / PAGE_SIZE));
if (p < 1 || p > totalPages) return;
currentPage = p;
renderPage();
}
async function refreshLogs() {
try {
var r = await fetch("/pool/logs?since=" + logSince).then(function(r){return r.json()});
var logs = r.logs || [];
if (logs.length === 0) return;
logSince = logs[logs.length - 1].id;
var box = document.getElementById("logBox");
var empty = box.querySelector(".log-empty");
if (empty) empty.remove();
for (var i = 0; i < logs.length; i++) {
var log = logs[i];
var cls = "log-line";
if (log.level === "success") cls += " log-success";
else if (log.level === "error") cls += " log-error";
else if (log.level === "warn") cls += " log-warn";
var line = document.createElement("div");
line.className = cls;
line.innerHTML = '<span class="log-time">' + fmtLogTime(log.time) + "</span>" + esc(log.message);
box.appendChild(line);
}
while (box.children.length > 200) {
box.removeChild(box.firstChild);
}
box.scrollTop = box.scrollHeight;
} catch(e) {}
}
async function doRegister() {
var btn = document.getElementById("regBtn");
var msg = document.getElementById("regMsg");
var count = parseInt(document.getElementById("regCount").value) || 5;
var provider = document.getElementById("regProvider").value || undefined;
var concurrency = parseInt(document.getElementById("regConcurrency").value) || 5;
btn.disabled = true;
btn.textContent = "\u6CE8\u518C\u4E2D...";
msg.className = "reg-msg";
msg.textContent = "";
try {
var payload = { count: count, concurrency: concurrency };
if (provider) payload.provider = provider;
var r = await fetch("/pool/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function(r){ return r.json() });
msg.className = "reg-msg " + (r.ok ? "ok" : "err");
msg.textContent = r.message;
} catch(e) {
msg.className = "reg-msg err";
msg.textContent = "\u8BF7\u6C42\u5931\u8D25: " + e.message;
} finally {
btn.disabled = false;
btn.textContent = "\u624B\u52A8\u6CE8\u518C";
}
}
async function refresh() {
var dot = document.getElementById("dot");
dot.classList.add("loading");
try {
var results = await Promise.allSettled([
fetch("/health").then(function(r){return r.json()}),
fetch("/pool/status").then(function(r){return r.json()}),
fetch("/v1/models").then(function(r){return r.json()})
]);
if (results[0].status === "fulfilled") {
document.getElementById("uptime").textContent = "\u8FD0\u884C\u65F6\u95F4: " + fmt(results[0].value.uptime);
}
if (results[1].status === "fulfilled") {
var p = results[1].value;
document.getElementById("s-total").textContent = p.total;
document.getElementById("s-active").textContent = p.active;
document.getElementById("s-inuse").textContent = p.in_use;
document.getElementById("s-login").textContent = p.needs_login;
document.getElementById("s-exhaust").textContent = p.exhausted;
document.getElementById("s-dead").textContent = p.dead;
var order = ["in_use","active","needs_login","exhausted","dead"];
allAccounts = (p.accounts||[]).slice().sort(function(a,b){ return order.indexOf(a.state) - order.indexOf(b.state); });
renderPage();
}
if (results[2].status === "fulfilled") {
var groups = {};
var models = results[2].value.data || [];
for (var j = 0; j < models.length; j++) {
var g = models[j].owned_by || "\u5176\u4ED6";
if (!groups[g]) groups[g] = [];
groups[g].push(models[j].id);
}
var mhtml = "";
for (var k = 0; k < GORDER.length; k++) {
var name = GORDER[k];
var items = groups[name];
if (!items || !items.length) continue;
var color = GCOLORS[name] || "#6366f1";
mhtml += '<div class="model-group"><div class="mg-title"><span class="mg-dot" style="background:' + color + '"></span>' + name + ' <span class="mg-count">' + items.length + " \u4E2A\u6A21\u578B</span></div><div class='mg-chips'>";
for (var l = 0; l < items.length; l++) {
mhtml += '<span class="chip">' + items[l] + "</span>";
}
mhtml += "</div></div>";
}
document.getElementById("modelGroups").innerHTML = mhtml || '<div class="empty">\u6682\u65E0\u6A21\u578B</div>';
}
} catch(e) {
console.error(e);
} finally {
dot.classList.remove("loading");
}
}
refresh();
refreshLogs();
setInterval(refresh, 5000);
setInterval(refreshLogs, 2000);
</script>
</body>
</html>`;
}