feat: AI 余额监控服务(平台/账号 CRUD、并发轮询、Telegram 阈值提醒)

This commit is contained in:
2026-08-04 23:51:06 +08:00
commit 17d2496a65
20 changed files with 2690 additions and 0 deletions
+559
View File
@@ -0,0 +1,559 @@
/* AI Balance Monitor 前端逻辑 */
(() => {
"use strict";
const TOKEN_KEY = "abm_token";
let token = localStorage.getItem(TOKEN_KEY) || "";
let accounts = [];
let platforms = [];
let settings = null;
let refreshTimer = null;
/* ---------- 工具 ---------- */
async function api(path, opts = {}) {
const headers = Object.assign({ "Content-Type": "application/json" }, opts.headers || {});
if (token) headers["Authorization"] = "Bearer " + token;
const resp = await fetch("/api" + path, Object.assign({}, opts, { headers }));
if (resp.status === 401 && !path.startsWith("/login")) {
logout();
throw new Error("登录已失效");
}
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.detail || ("HTTP " + resp.status));
return data;
}
function toast(msg, kind = "ok") {
const el = document.createElement("div");
el.className = "toast " + kind;
el.innerHTML = `<span class="toast-dot"></span><span>${escapeHtml(msg)}</span>`;
document.getElementById("toast-root").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 250);
}, 2600);
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function fmtBalance(v) {
if (v === null || v === undefined) return null;
const n = Number(v);
if (!Number.isFinite(n)) return null;
return n >= 100 ? n.toFixed(2) : n >= 1 ? n.toFixed(3) : n.toFixed(4);
}
function fmtTime(s) {
if (!s) return "—";
return s;
}
/* ---------- 品牌图标映射(@lobehub/icons 键 → 品牌色) ---------- */
const BRAND_COLORS = {
OpenAI: "#10A37F", GPT: "#10A37F", AzureOpenAI: "#0078D4",
Anthropic: "#D97757", Claude: "#D97757",
Google: "#4285F4", Gemini: "#4285F4", GoogleAIStudio: "#4285F4",
DeepSeek: "#4D6BFE",
MoonshotAI: "#1E1E1E", Kimi: "#1E1E1E",
ZhipuAI: "#3859FF", ChatGLM: "#3859FF",
Qwen: "#615CED", Tongyi: "#615CED", Aliyun: "#FF6A00",
Groq: "#F55036",
MistralAI: "#FF7000", Mistral: "#FF7000",
Meta: "#0668E1", Llama: "#0668E1",
XAI: "#1B1B1B", XAIGrok: "#1B1B1B", Grok: "#1B1B1B",
OpenRouter: "#B35CFF",
Perplexity: "#1FB8CD",
Cohere: "#39594D",
HuggingFace: "#FFD21E",
TogetherAI: "#FFB1D9",
Replicate: "#F26E1E",
Baidu: "#2932E1", Qianfan: "#2932E1",
MiniMax: "#FFC80A",
SiliconFlow: "#3E9E6B",
FireworksAI: "#FF2D20",
Cerebras: "#00D1B2",
NVIDIA: "#76B900",
Volcengine: "#3370FF", Doubao: "#3370FF",
TencentCloud: "#006EFF", Hunyuan: "#006EFF",
};
function brandStyle(iconKey) {
const color = BRAND_COLORS[iconKey] || "#8A8F98";
const abbr = (iconKey || "").slice(0, 2).toUpperCase() || "AI";
return { color, abbr };
}
/* ---------- 视图/状态 ---------- */
function switchView(name) {
document.querySelectorAll(".view").forEach((v) => v.classList.add("hidden"));
document.getElementById("view-" + name).classList.remove("hidden");
document.querySelectorAll(".tab").forEach((t) => {
t.classList.toggle("active", t.dataset.view === name);
});
if (name === "platforms") renderPlatforms();
if (name === "settings") loadSettings();
}
async function refresh() {
try {
const [acc, plat] = await Promise.all([api("/accounts"), api("/platforms")]);
accounts = acc;
platforms = plat;
renderAccounts();
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
const d = new Date();
document.getElementById("refresh-time").textContent =
d.toLocaleTimeString("zh-CN", { hour12: false });
} catch (e) {
if (e.message !== "登录已失效") console.warn("刷新失败", e);
}
}
/* ---------- 渲染:账号 ---------- */
function statusInfo(a) {
if (!a.enabled) return { cls: "disabled", text: "已禁用" };
if (a.last_status === "disabled") return { cls: "disabled", text: "已禁用" };
if (a.last_status === "error") return { cls: "err", text: "异常" };
if (a.last_status === "ok") {
if (a.last_balance !== null && a.last_balance < a.threshold) {
return { cls: "warn", text: "低于阈值" };
}
return { cls: "ok", text: "正常" };
}
return { cls: "pending", text: "待检查" };
}
function accountCard(a, idx) {
const st = statusInfo(a);
const plat = platforms.find((p) => p.id === a.platform_id) || {};
const b = brandStyle(plat.icon || "");
const bal = fmtBalance(a.last_balance);
const th = fmtBalance(a.threshold);
const below = a.last_status === "ok" && bal !== null && Number(a.last_balance) < a.threshold;
const cardCls = ["account-card", st.cls === "warn" ? "below" : "", st.cls === "err" ? "error" : "",
st.cls === "disabled" ? "disabled" : ""].join(" ").trim();
const pct = th && bal !== null ? Math.min(100, (Number(a.last_balance) / Number(th)) * 100) : 100;
const fillCls = below ? "threshold-fill below" : "threshold-fill";
return `
<div class="${cardCls}" style="animation-delay:${Math.min(idx, 12) * 40}ms">
<div class="card-top">
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
<div class="card-title">
<div class="card-platform">${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}</div>
<div class="card-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div>
</div>
</div>
<div class="balance-row">
${bal !== null
? `<span class="balance-value ${below ? "below" : ""}">${bal}</span><span class="balance-currency">${escapeHtml(plat.currency || "")}</span>`
: `<span class="balance-empty">—</span>`}
</div>
<div class="threshold-bar" title="阈值 ${th ?? "—"} ${escapeHtml(plat.currency || "")}">
<div class="${fillCls}" style="width:${bal === null ? 0 : pct}%"></div>
</div>
<div class="card-meta">
<span class="badge-pill ${st.cls}">${st.text}</span>
<span>上次检查:${fmtTime(a.last_check_at)}</span>
${a.last_error ? `<span style="color:var(--err)">${escapeHtml(a.last_error)}</span>` : ""}
</div>
<div class="card-actions">
<button class="btn sm" data-act="history" data-id="${a.id}">历史</button>
<button class="btn sm" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled" : ""}>立即检查</button>
<button class="btn sm" data-act="edit" data-id="${a.id}">编辑</button>
<button class="btn sm danger" data-act="del" data-id="${a.id}">删除</button>
</div>
</div>`;
}
function renderAccounts() {
const grid = document.getElementById("account-grid");
const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 };
accounts.forEach((a) => {
const st = statusInfo(a);
if (st.cls === "ok") stats.ok++;
else if (st.cls === "warn") stats.below++;
else if (st.cls === "err") stats.error++;
else if (st.cls === "disabled") stats.disabled++;
else stats.pending++;
});
document.getElementById("stat-total").textContent = stats.total;
document.getElementById("stat-ok").textContent = stats.ok;
document.getElementById("stat-below").textContent = stats.below;
document.getElementById("stat-error").textContent = stats.error;
document.getElementById("stat-disabled").textContent = stats.disabled;
document.getElementById("stat-pending").textContent = stats.pending;
grid.innerHTML = accounts.map(accountCard).join("");
document.getElementById("empty-hint").classList.toggle("hidden", accounts.length > 0);
}
/* ---------- 渲染:平台 ---------- */
function renderPlatforms() {
const list = document.getElementById("platform-list");
if (platforms.length === 0) {
list.innerHTML = `<div class="empty-hint">还没有平台,点击右上角「+ 添加平台」创建。</div>`;
return;
}
list.innerHTML = platforms.map((p, i) => {
const b = brandStyle(p.icon || "");
return `
<div class="platform-row ${p.enabled ? "" : "platform-off"}" style="animation-delay:${Math.min(i, 10) * 35}ms">
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
<div class="platform-info">
<div class="platform-name">${escapeHtml(p.name)}
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
</div>
<div class="platform-meta">
${escapeHtml(p.method)} ${escapeHtml(p.url)} ·
提取 ${escapeHtml(p.balance_path)} ·
${escapeHtml(p.currency)} ·
间隔 ${p.interval_seconds || "全局"}s ·
${p.account_count} 个账号
</div>
</div>
<div class="platform-actions">
<button class="btn sm" data-act="toggle" data-id="${p.id}">${p.enabled ? "停用" : "启用"}</button>
<button class="btn sm" data-act="edit" data-id="${p.id}">编辑</button>
<button class="btn sm danger" data-act="del" data-id="${p.id}">删除</button>
</div>
</div>`;
}).join("");
}
/* ---------- 模态框 ---------- */
function openModal(html, onMount) {
const root = document.getElementById("modal-root");
const mask = document.createElement("div");
mask.className = "modal-mask";
mask.innerHTML = html;
mask.addEventListener("click", (e) => { if (e.target === mask) closeModal(mask); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(mask); }, { once: true });
root.appendChild(mask);
if (onMount) onMount(mask);
return mask;
}
function closeModal(mask) {
if (mask) mask.remove();
}
function confirmDialog(text, onOk) {
const mask = openModal(`
<div class="modal confirm-modal">
<h3>确认操作</h3>
<p class="confirm-text">${text}</p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn danger" data-ok>确认删除</button>
</div>
</div>`);
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
}
/* 平台表单 */
function platformForm(p) {
const isEdit = !!p;
const v = p || {};
const headersStr = Object.keys(v.headers || {}).length ? JSON.stringify(v.headers, null, 2) : '{\n "Authorization": "Bearer {{apiKey}}"\n}';
openModal(`
<div class="modal">
<h3>${isEdit ? "编辑平台" : "添加平台"}</h3>
<div class="form-grid">
<div class="form-row"><label>名称 *</label><input id="pf-name" value="${escapeHtml(v.name || "")}" placeholder="如 OpenAI"></div>
<div class="form-row"><label>货币单位 *</label><input id="pf-currency" value="${escapeHtml(v.currency || "USD")}"></div>
<div class="form-row"><label>图标键(@lobehub/icons</label><input id="pf-icon" value="${escapeHtml(v.icon || "")}" placeholder="如 OpenAI / DeepSeek"></div>
<div class="form-row">
<label>请求方法</label>
<select id="pf-method">
<option value="GET" ${(v.method || "GET") === "GET" ? "selected" : ""}>GET</option>
<option value="POST" ${v.method === "POST" ? "selected" : ""}>POST</option>
</select>
</div>
<div class="form-row full"><label>URL *(支持 {{apiKey}}</label><input id="pf-url" value="${escapeHtml(v.url || "")}" placeholder="https://api.openai.com/v1/dashboard/billing/credit_grants?api_key={{apiKey}}"></div>
<div class="form-row full"><label>HeadersJSON,值支持 {{apiKey}}</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div>
<div class="form-row full"><label>BodyPOST 时使用,JSON 模板,支持 {{apiKey}}</label><textarea id="pf-body" placeholder='{"api_key": "{{apiKey}}"}'>${escapeHtml(v.body || "")}</textarea></div>
<div class="form-row"><label>余额提取路径 *</label><input id="pf-path" value="${escapeHtml(v.balance_path || "")}" placeholder="data.balance 或 data[0].balance"></div>
<div class="form-row"><label>监控间隔(秒,留空用全局)</label><input id="pf-interval" type="number" min="10" value="${v.interval_seconds ?? ""}" placeholder="全局 ${settings ? settings.global_interval_seconds : 300}s"></div>
<div class="form-row"><label>重试次数(留空用全局 ${settings ? settings.retry_count : 2}</label><input id="pf-retry" type="number" min="0" max="10" value="${v.retry_count ?? ""}"></div>
<div class="form-row"><label>超时秒数(留空用全局 ${settings ? settings.timeout_seconds : 10}</label><input id="pf-timeout" type="number" min="1" max="120" value="${v.timeout_seconds ?? ""}"></div>
<div class="form-row full"><label>备注</label><input id="pf-note" value="${escapeHtml(v.note || "")}"></div>
</div>
<p class="form-hint">提示:apikey 在 URL / Header / Body 中统一用 {{apiKey}} 占位,添加账号时自动替换。</p>
<p class="modal-error" id="pf-error"></p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn primary" id="pf-save">保存</button>
</div>
</div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#pf-save").onclick = async () => {
const payload = {
name: val("#pf-name"), currency: val("#pf-currency"), icon: val("#pf-icon"),
method: val("#pf-method"), url: val("#pf-url"), balance_path: val("#pf-path"),
note: val("#pf-note"),
interval_seconds: numOrNull("#pf-interval"), retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"), enabled: p ? p.enabled : true,
};
let headers = {};
try { headers = JSON.parse(val("#pf-headers") || "{}"); }
catch (e) { err("#pf-error", "Headers 不是合法 JSON"); return; }
payload.headers = headers;
if (!payload.name || !payload.url || !payload.balance_path) { err("#pf-error", "名称 / URL / 提取路径必填"); return; }
if (payload.method === "POST") {
try { JSON.parse(val("#pf-body") || "{}"); } catch (e) { err("#pf-error", "Body 不是合法 JSON"); return; }
}
payload.body = val("#pf-body");
try {
if (isEdit) await api("/platforms/" + p.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/platforms", { method: "POST", body: JSON.stringify(payload) });
closeModal(mask);
toast(isEdit ? "平台已更新" : "平台已添加");
await refresh();
switchView("platforms");
} catch (e) { err("#pf-error", e.message); }
};
});
}
/* 账号表单 */
function accountForm(a) {
const isEdit = !!a;
const v = a || {};
const opts = platforms.map((p) =>
`<option value="${p.id}" ${v.platform_id === p.id ? "selected" : ""}>${escapeHtml(p.name)}</option>`).join("");
openModal(`
<div class="modal">
<h3>${isEdit ? "编辑账号" : "添加账号"}</h3>
<div class="form-grid">
<div class="form-row"><label>所属平台 *</label><select id="ac-platform">${opts}</select></div>
<div class="form-row"><label>账号名称 *</label><input id="ac-name" value="${escapeHtml(v.name || "")}" placeholder="如 主账号"></div>
<div class="form-row full"><label>API Key *</label><input id="ac-key" type="password" value="${escapeHtml(v.api_key || "")}" placeholder="sk-..."></div>
<div class="form-row"><label>提醒阈值(余额低于此值提醒)</label><input id="ac-threshold" type="number" step="any" min="0" value="${v.threshold ?? 0}"></div>
<div class="form-row">
<label>启用监控</label>
<select id="ac-enabled">
<option value="1" ${v.enabled !== false ? "selected" : ""}>启用</option>
<option value="0" ${v.enabled === false ? "selected" : ""}>停用</option>
</select>
</div>
<div class="form-row full"><label>备注</label><input id="ac-note" value="${escapeHtml(v.note || "")}"></div>
</div>
<p class="modal-error" id="ac-error"></p>
<div class="modal-actions">
<button class="btn" data-cancel>取消</button>
<button class="btn primary" id="ac-save">保存</button>
</div>
</div>`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#ac-save").onclick = async () => {
const payload = {
platform_id: Number(val("#ac-platform")), name: val("#ac-name"),
api_key: val("#ac-key"), threshold: Number(val("#ac-threshold") || 0),
enabled: val("#ac-enabled") === "1", note: val("#ac-note"),
};
if (!payload.platform_id || !payload.name || !payload.api_key) { err("#ac-error", "平台 / 名称 / API Key 必填"); return; }
try {
if (isEdit) await api("/accounts/" + a.id, { method: "PUT", body: JSON.stringify(payload) });
else await api("/accounts", { method: "POST", body: JSON.stringify(payload) });
closeModal(mask);
toast(isEdit ? "账号已更新" : "账号已添加");
await refresh();
switchView("monitor");
} catch (e) { err("#ac-error", e.message); }
};
});
}
/* 历史弹窗 */
async function showHistory(accountId) {
const acc = accounts.find((a) => a.id === accountId);
if (!acc) return;
const plat = platforms.find((p) => p.id === acc.platform_id) || {};
const mask = openModal(`
<div class="modal history-modal">
<h3>余额历史 · ${escapeHtml(acc.name)}</h3>
<div id="hist-body" style="color:var(--text-3);font-size:12px">加载中…</div>
</div>`);
try {
const hist = await api(`/accounts/${accountId}/history?limit=30`);
const body = mask.querySelector("#hist-body");
if (hist.length === 0) {
body.innerHTML = `<div style="padding:20px 0;text-align:center">暂无记录(检查成功后自动记录)</div>`;
return;
}
const max = Math.max(...hist.map((h) => h.balance), 1e-9);
const bars = hist.map((h) =>
`<i title="${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")} @ ${escapeHtml(h.checked_at)}" style="height:${Math.max(6, (h.balance / max) * 100)}%"></i>`).join("");
const items = [...hist].reverse().map((h) =>
`<div class="history-item"><span>${escapeHtml(h.checked_at)}</span><span class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</span></div>`).join("");
body.innerHTML = `<div class="spark">${bars}</div>${items}`;
} catch (e) {
mask.querySelector("#hist-body").textContent = "加载失败:" + e.message;
}
}
/* ---------- 设置 ---------- */
async function loadSettings() {
try {
settings = await api("/settings");
document.getElementById("set-interval").value = settings.global_interval_seconds;
document.getElementById("set-retry").value = settings.retry_count;
document.getElementById("set-timeout").value = settings.timeout_seconds;
document.getElementById("set-workers").value = settings.max_workers;
document.getElementById("set-token").value = settings.telegram_bot_token;
document.getElementById("set-chatid").value = settings.telegram_chat_id;
} catch (e) { toast(e.message, "err"); }
}
/* ---------- 事件 ---------- */
function val(id) { return document.getElementById(id).value.trim(); }
function numOrNull(id) { const s = val(id); return s === "" ? null : Number(s); }
function err(id, msg) { document.getElementById(id).textContent = msg; }
function bindEvents() {
document.getElementById("login-btn").onclick = doLogin;
document.getElementById("login-password").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
document.getElementById("logout-btn").onclick = logout;
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => switchView(t.dataset.view)));
document.getElementById("add-platform-btn").onclick = () => platformForm(null);
document.getElementById("save-settings-btn").onclick = saveSettings;
document.getElementById("change-pwd-btn").onclick = changePassword;
document.getElementById("account-grid").addEventListener("click", onAccountAction);
document.getElementById("platform-list").addEventListener("click", onPlatformAction);
}
async function doLogin() {
const pw = val("login-password");
if (!pw) return;
try {
const data = await api("/login", { method: "POST", body: JSON.stringify({ password: pw }) });
token = data.token;
localStorage.setItem(TOKEN_KEY, token);
document.getElementById("login-error").textContent = "";
document.getElementById("login-view").classList.add("hidden");
document.getElementById("app-view").classList.remove("hidden");
await refresh();
startRefreshTimer();
} catch (e) {
document.getElementById("login-error").textContent = e.message;
}
}
function logout() {
if (token) api("/logout", { method: "POST" }).catch(() => {});
token = "";
localStorage.removeItem(TOKEN_KEY);
stopRefreshTimer();
document.getElementById("app-view").classList.add("hidden");
document.getElementById("login-view").classList.remove("hidden");
document.getElementById("login-password").value = "";
}
function startRefreshTimer() {
stopRefreshTimer();
refreshTimer = setInterval(refresh, 10000);
}
function stopRefreshTimer() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
async function onAccountAction(e) {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const id = Number(btn.dataset.id);
const act = btn.dataset.act;
if (act === "edit") accountForm(accounts.find((a) => a.id === id));
else if (act === "del") {
const a = accounts.find((x) => x.id === id);
confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); }
catch (err2) { toast(err2.message, "err"); }
});
} else if (act === "check") {
btn.disabled = true;
try { await api(`/accounts/${id}/check`, { method: "POST" }); toast("已开始检查"); }
catch (err2) { toast(err2.message, "err"); btn.disabled = false; }
setTimeout(refresh, 1500);
} else if (act === "history") {
showHistory(id);
}
}
async function onPlatformAction(e) {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const id = Number(btn.dataset.id);
const act = btn.dataset.act;
const p = platforms.find((x) => x.id === id);
if (act === "edit") platformForm(p);
else if (act === "toggle") {
try {
await api("/platforms/" + id, { method: "PUT", body: JSON.stringify({ enabled: !p.enabled }) });
toast(p.enabled ? "平台已停用" : "平台已启用");
await refresh();
renderPlatforms();
} catch (err2) { toast(err2.message, "err"); }
} else if (act === "del") {
confirmDialog(`确定删除平台「${escapeHtml(p ? p.name : id)}」?<br>其下 ${p ? p.account_count : 0} 个账号及历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/platforms/" + id, { method: "DELETE" }); toast("平台已删除"); await refresh(); switchView("platforms"); }
catch (err2) { toast(err2.message, "err"); }
});
}
}
async function saveSettings() {
try {
await api("/settings", {
method: "PUT",
body: JSON.stringify({
global_interval_seconds: Number(val("set-interval")),
retry_count: Number(val("set-retry")),
timeout_seconds: Number(val("set-timeout")),
max_workers: Number(val("set-workers")),
telegram_bot_token: val("set-token"),
telegram_chat_id: val("set-chatid"),
}),
});
toast("设置已保存");
await loadSettings();
} catch (e) { toast(e.message, "err"); }
}
async function changePassword() {
try {
await api("/settings/password", {
method: "POST",
body: JSON.stringify({ old_password: val("set-oldpwd"), new_password: val("set-newpwd") }),
});
document.getElementById("set-oldpwd").value = "";
document.getElementById("set-newpwd").value = "";
toast("密码已修改");
} catch (e) { toast(e.message, "err"); }
}
/* ---------- 启动 ---------- */
function init() {
bindEvents();
if (token) {
document.getElementById("login-view").classList.add("hidden");
document.getElementById("app-view").classList.remove("hidden");
refresh().then(() => startRefreshTimer()).catch(() => logout());
}
}
init();
})();
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Balance Monitor</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- 登录视图 -->
<div id="login-view" class="login-view">
<div class="login-card">
<div class="login-logo"></div>
<h1>AI Balance Monitor</h1>
<p class="login-sub">多平台 AI 余额监控面板</p>
<input id="login-password" type="password" placeholder="管理密码" autocomplete="current-password">
<button id="login-btn" class="btn primary block">登录</button>
<p id="login-error" class="login-error"></p>
</div>
</div>
<!-- 主应用 -->
<div id="app-view" class="app-view hidden">
<header class="topbar">
<div class="brand">
<span class="brand-dot"></span>
<span class="brand-name">AI Balance Monitor</span>
</div>
<nav class="tabs">
<button class="tab active" data-view="monitor">监控</button>
<button class="tab" data-view="platforms">平台</button>
<button class="tab" data-view="settings">设置</button>
</nav>
<div class="topbar-right">
<span id="refresh-time" class="refresh-time"></span>
<button id="logout-btn" class="btn ghost sm">登出</button>
</div>
</header>
<main class="content">
<!-- 监控视图 -->
<section id="view-monitor" class="view">
<div class="stats-bar">
<div class="stat"><span id="stat-total">0</span><label>账号</label></div>
<div class="stat ok"><span id="stat-ok">0</span><label>正常</label></div>
<div class="stat warn"><span id="stat-below">0</span><label>低于阈值</label></div>
<div class="stat err"><span id="stat-error">0</span><label>异常</label></div>
<div class="stat disabled"><span id="stat-disabled">0</span><label>已禁用</label></div>
<div class="stat pending"><span id="stat-pending">0</span><label>待检查</label></div>
</div>
<div id="account-grid" class="account-grid"></div>
<div id="empty-hint" class="empty-hint hidden">
<p>还没有账号。先到「平台」页添加一个平台,再回来添加账号。</p>
</div>
</section>
<!-- 平台视图 -->
<section id="view-platforms" class="view hidden">
<div class="view-head">
<h2>平台配置</h2>
<button id="add-platform-btn" class="btn primary">+ 添加平台</button>
</div>
<div id="platform-list" class="platform-list"></div>
</section>
<!-- 设置视图 -->
<section id="view-settings" class="view hidden">
<div class="view-head"><h2>设置</h2></div>
<div class="settings-card">
<h3>监控</h3>
<div class="form-row">
<label>全局监控间隔(秒)</label>
<input id="set-interval" type="number" min="10">
</div>
<div class="form-row">
<label>默认重试次数</label>
<input id="set-retry" type="number" min="0" max="10">
</div>
<div class="form-row">
<label>默认超时(秒)</label>
<input id="set-timeout" type="number" min="1" max="120">
</div>
<div class="form-row">
<label>并发数</label>
<input id="set-workers" type="number" min="1" max="64">
</div>
</div>
<div class="settings-card">
<h3>Telegram 提醒</h3>
<div class="form-row">
<label>Bot Token</label>
<input id="set-token" type="password" placeholder="123456:ABC-DEF...">
</div>
<div class="form-row">
<label>Chat ID</label>
<input id="set-chatid" type="text" placeholder="如 123456789">
</div>
</div>
<div class="settings-card">
<h3>安全</h3>
<div class="form-row">
<label>旧密码</label>
<input id="set-oldpwd" type="password" autocomplete="current-password">
</div>
<div class="form-row">
<label>新密码(≥4 位)</label>
<input id="set-newpwd" type="password" autocomplete="new-password">
</div>
</div>
<div class="settings-actions">
<button id="save-settings-btn" class="btn primary">保存设置</button>
<button id="change-pwd-btn" class="btn">修改密码</button>
<span class="settings-hint">端口需修改 config.json 后重启生效。</span>
</div>
</section>
</main>
</div>
<!-- 模态框 -->
<div id="modal-root"></div>
<div id="toast-root"></div>
<script src="/static/app.js"></script>
</body>
</html>
+409
View File
@@ -0,0 +1,409 @@
/* ===== 设计基础 ===== */
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--radius: 12px;
--radius-sm: 8px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
--bg: #f5f6f8;
--bg-elev: #ffffff;
--bg-elev-2: #f0f1f4;
--border: rgba(15, 23, 42, 0.08);
--border-strong: rgba(15, 23, 42, 0.14);
--text: #1a1d24;
--text-2: #5b6270;
--text-3: #9aa1ad;
--accent: #3b6ef6;
--accent-strong: #2f5ae0;
--accent-soft: rgba(59, 110, 246, 0.1);
--ok: #16a34a;
--ok-soft: rgba(22, 163, 74, 0.12);
--warn: #d97706;
--warn-soft: rgba(217, 119, 6, 0.13);
--err: #dc2626;
--err-soft: rgba(220, 38, 38, 0.11);
--disabled-soft: rgba(100, 116, 139, 0.14);
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-md: 0 4px 16px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.16);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1015;
--bg-elev: #171a21;
--bg-elev-2: #1e222b;
--border: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.13);
--text: #e8eaef;
--text-2: #9aa1ad;
--text-3: #6b7280;
--accent: #5b8cff;
--accent-strong: #3b6ef6;
--accent-soft: rgba(91, 140, 255, 0.14);
--ok: #34d399;
--ok-soft: rgba(52, 211, 153, 0.13);
--warn: #fbbf24;
--warn-soft: rgba(251, 191, 36, 0.13);
--err: #f87171;
--err-soft: rgba(248, 113, 113, 0.13);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5);
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* ===== 通用组件 ===== */
.btn {
font-family: inherit;
font-size: 13px;
font-weight: 500;
padding: 8px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-elev);
color: var(--text);
cursor: pointer;
transition: transform 120ms var(--ease-out), background 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.btn:active { transform: scale(0.97); }
.btn:hover { background: var(--bg-elev-2); }
.btn.primary {
background: var(--accent);
border-color: transparent;
color: #fff;
}
.btn.primary:hover { background: var(--accent-strong); }
.btn.danger { color: var(--err); }
.btn.danger:hover { background: var(--err-soft); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--text-2); }
.btn.ghost:hover { background: var(--bg-elev-2); color: var(--text); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.block { width: 100%; padding: 10px; font-size: 14px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea, select {
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg-elev);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 8px 10px;
width: 100%;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
textarea { resize: vertical; min-height: 72px; font-family: "Cascadia Code", Consolas, monospace; }
label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom: 5px; display: block; }
/* ===== 登录 ===== */
.login-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
width: 320px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px 28px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 14px;
animation: card-in 260ms var(--ease-out);
}
.login-logo { font-size: 28px; text-align: center; color: var(--accent); }
.login-card h1 { font-size: 19px; text-align: center; letter-spacing: -0.02em; }
.login-sub { text-align: center; color: var(--text-3); font-size: 12px; margin-top: -8px; }
.login-error { color: var(--err); font-size: 12px; text-align: center; min-height: 16px; }
/* ===== 顶栏 ===== */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
height: 52px;
background: color-mix(in srgb, var(--bg-elev) 72%, transparent);
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; letter-spacing: -0.01em; }
.brand-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-soft);
}
.tabs { display: flex; gap: 4px; }
.tab {
font-family: inherit; font-size: 13px; font-weight: 500;
padding: 6px 14px; border: none; border-radius: 999px;
background: transparent; color: var(--text-2); cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.tab:hover { background: var(--bg-elev-2); color: var(--text); }
.tab.active { background: var(--accent-soft); color: var(--accent); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.refresh-time { font-size: 12px; color: var(--text-3); }
/* ===== 内容 ===== */
.content { max-width: 1080px; margin: 0 auto; padding: 24px; }
.view-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.view-head h2 { font-size: 17px; letter-spacing: -0.02em; }
/* 统计条 */
.stats-bar {
display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;
}
.stat {
flex: 1; min-width: 90px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
display: flex; flex-direction: column; gap: 2px;
box-shadow: var(--shadow-sm);
}
.stat span { font-size: 22px; font-weight: 650; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stat label { margin: 0; font-size: 11px; color: var(--text-3); }
.stat.ok span { color: var(--ok); }
.stat.warn span { color: var(--warn); }
.stat.err span { color: var(--err); }
.stat.disabled span { color: var(--text-2); }
.stat.pending span { color: var(--text-3); }
/* ===== 账号卡片 ===== */
.account-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 14px;
}
.account-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 10px;
opacity: 0;
transform: translateY(8px);
animation: card-in 300ms var(--ease-out) forwards;
transition: transform 180ms var(--ease-out), box-shadow 180ms var(--ease-out), border-color 180ms ease;
}
@media (hover: hover) and (pointer: fine) {
.account-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
}
.account-card.below { border-color: color-mix(in srgb, var(--warn) 55%, transparent); }
.account-card.error { border-color: color-mix(in srgb, var(--err) 55%, transparent); }
.account-card.disabled { opacity: 0.65; }
.card-top { display: flex; align-items: center; gap: 10px; }
.badge {
width: 34px; height: 34px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px; color: #fff;
flex-shrink: 0;
letter-spacing: 0.02em;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18);
}
.card-title { min-width: 0; }
.card-platform { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
.card-name { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.balance-row { display: flex; align-items: baseline; gap: 6px; }
.balance-value { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.balance-value.below { color: var(--warn); }
.balance-value.error { color: var(--err); }
.balance-currency { font-size: 13px; color: var(--text-2); font-weight: 500; }
.balance-empty { color: var(--text-3); font-size: 22px; font-weight: 600; }
.card-meta { font-size: 11.5px; color: var(--text-3); display: flex; flex-direction: column; gap: 2px; }
.card-meta .badge-pill { align-self: flex-start; }
.badge-pill {
font-size: 11px; font-weight: 500;
padding: 2px 8px; border-radius: 999px;
}
.badge-pill.ok { background: var(--ok-soft); color: var(--ok); }
.badge-pill.warn { background: var(--warn-soft); color: var(--warn); }
.badge-pill.err { background: var(--err-soft); color: var(--err); }
.badge-pill.disabled { background: var(--disabled-soft); color: var(--text-2); }
.badge-pill.pending { background: var(--bg-elev-2); color: var(--text-3); }
.card-actions { display: flex; gap: 6px; margin-top: 2px; }
/* 阈值条 */
.threshold-bar {
height: 4px; border-radius: 2px;
background: var(--bg-elev-2);
overflow: hidden;
}
.threshold-fill {
height: 100%; border-radius: 2px;
background: var(--ok);
transition: width 300ms var(--ease-out), background 200ms ease;
}
.threshold-fill.below { background: var(--warn); }
/* ===== 平台列表 ===== */
.platform-list { display: flex; flex-direction: column; gap: 10px; }
.platform-row {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
display: flex; align-items: center; gap: 12px;
box-shadow: var(--shadow-sm);
opacity: 0;
animation: card-in 260ms var(--ease-out) forwards;
}
.platform-info { flex: 1; min-width: 0; }
.platform-name { font-weight: 600; display: flex; align-items: center; gap: 8px; }
.platform-meta { font-size: 11.5px; color: var(--text-3); margin-top: 2px; }
.platform-actions { display: flex; gap: 6px; }
.platform-off { opacity: 0.55; }
/* ===== 设置 ===== */
.settings-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
margin-bottom: 14px;
box-shadow: var(--shadow-sm);
opacity: 0;
animation: card-in 260ms var(--ease-out) forwards;
}
.settings-card h3 { font-size: 13px; margin-bottom: 12px; color: var(--text-2); font-weight: 600; }
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; max-width: 420px; }
.form-row:last-child { margin-bottom: 0; }
.settings-actions { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.settings-hint { font-size: 12px; color: var(--text-3); }
/* ===== 模态框 ===== */
.modal-mask {
position: fixed; inset: 0; z-index: 100;
background: rgba(10, 12, 18, 0.45);
display: flex; align-items: center; justify-content: center;
padding: 20px;
animation: fade-in 180ms ease;
backdrop-filter: blur(2px);
}
.modal {
width: 480px; max-width: 100%;
max-height: 86vh; overflow-y: auto;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-lg);
padding: 22px 24px;
animation: modal-in 220ms var(--ease-out);
}
.modal h3 { font-size: 16px; letter-spacing: -0.02em; margin-bottom: 16px; }
.modal .form-row { max-width: none; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.modal-error { color: var(--err); font-size: 12px; min-height: 16px; margin-top: 8px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-grid .full { grid-column: 1 / -1; }
.form-hint { font-size: 11px; color: var(--text-3); margin-top: 4px; }
/* 历史弹窗 */
.history-modal .modal { width: 420px; }
.history-list { display: flex; flex-direction: column; gap: 6px; max-height: 300px; overflow-y: auto; }
.history-item {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 10px; border-radius: var(--radius-sm);
background: var(--bg-elev-2);
font-size: 12.5px; font-variant-numeric: tabular-nums;
}
.history-item .h-bal { font-weight: 600; }
.spark { display: flex; align-items: flex-end; gap: 2px; height: 34px; margin: 10px 0 4px; }
.spark i {
flex: 1; background: var(--accent);
border-radius: 2px 2px 0 0;
opacity: 0.85;
min-height: 2px;
transition: opacity 150ms ease;
}
.spark i:hover { opacity: 1; }
/* 确认弹窗 */
.confirm-modal .modal { width: 340px; }
.confirm-text { color: var(--text-2); font-size: 13px; margin-bottom: 18px; line-height: 1.6; }
/* ===== Toast ===== */
#toast-root {
position: fixed; bottom: 20px; right: 20px; z-index: 200;
display: flex; flex-direction: column; gap: 8px;
}
.toast {
background: var(--bg-elev);
border: 1px solid var(--border-strong);
color: var(--text);
font-size: 13px;
padding: 10px 16px;
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex; align-items: center; gap: 8px;
opacity: 0;
transform: translateY(10px);
transition: opacity 220ms var(--ease-out), transform 220ms var(--ease-out);
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.ok .toast-dot { background: var(--ok); }
.toast.err .toast-dot { background: var(--err); }
.toast-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
/* 空状态 */
.empty-hint {
text-align: center; color: var(--text-3);
padding: 60px 0; font-size: 13px;
}
/* ===== 动画 ===== */
@keyframes card-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
.account-card { opacity: 1; transform: none; }
}