/* AI Balance Monitor 前端逻辑 */
(() => {
"use strict";
const TOKEN_KEY = "abm_token";
let token = localStorage.getItem(TOKEN_KEY) || "";
let accounts = [];
let platforms = [];
let providersList = [];
let settings = null;
let refreshTimer = null;
let filterStatus = "all";
let filterPlatform = 0;
const checkingIds = new Set();
let historyCache = {};
let hasRendered = false; // 首次渲染保留进入动画,自动刷新静默更新
const accountWindows = {}; // {accountId: 'all'|'1h'|'1d'|'1w'|'1mo'|'1yr'}
const windowCache = {}; // {'aid:win': [...]}
const WINDOW_LABELS = {
all: "全部记录", "1h": "最近 1 小时", "1d": "最近 1 天",
"1w": "最近 1 周", "1mo": "最近 1 个月", "1yr": "最近 1 年",
};
const WINDOW_KEYS = ["all", "1h", "1d", "1w", "1mo", "1yr"];
/* ---------- 工具 ---------- */
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 = `${escapeHtml(msg)}`;
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) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[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;
}
/* 内联 SVG 小图标(lucide 风格描边) */
const ICONS = {
refresh: '',
clock: '',
pencil: '',
trash: '',
alert: '',
};
/* 容错 JSON 解析:允许用户写 Python 风格的单引号;失败返回 null */
function parseJsonInput(text, label, errorId) {
let s = (text || "").trim();
if (!s) return {};
try {
return JSON.parse(s);
} catch (_) {
try {
return JSON.parse(s.replace(/'/g, '"'));
} catch (_2) {
err(errorId, `${label} 不是合法 JSON:键和值必须用双引号,如 {"key": "value"}`);
return null;
}
}
}
/* ---------- 品牌图标映射(@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, hist, provs] = await Promise.all([
api("/accounts"), api("/platforms"), api("/history"), api("/providers"),
]);
accounts = acc;
platforms = plat;
historyCache = hist || {};
providersList = provs || [];
renderAccounts(!hasRendered);
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms(!hasRendered);
hasRendered = true;
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, animate = true) {
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 checking = checkingIds.has(a.id);
const cardCls = ["account-card",
st.cls === "warn" ? "below" : "",
st.cls === "err" ? "error" : "",
st.cls === "disabled" ? "disabled" : "",
st.cls === "pending" ? "pending" : "",
].join(" ").trim();
const animStyle = animate ? `animation-delay:${Math.min(idx, 12) * 40}ms` : "animation:none";
const win = accountWindows[a.id] || "all";
let hist;
if (win === "all") {
hist = historyCache[a.id] || [];
} else {
hist = windowCache[a.id + ":" + win] || historyCache[a.id] || [];
}
const spark = sparkline(hist, a.threshold, st.cls);
const winBtns = WINDOW_KEYS.map((k) =>
``
).join("");
const checkBtn = checking
? ``
: ``;
return `
${escapeHtml(b.abbr)}
${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}
${escapeHtml(a.name)}
${st.text}
${bal !== null
? `${bal}${escapeHtml(plat.currency || "")}`
: `—`}
${th !== null ? `/ 阈值 ${th}` : ""}
${spark
? `
余额历史 · ${WINDOW_LABELS[win]}(${hist.length} 条${th !== null ? `,虚线阈值 ${th}` : ""})
${spark}
${winBtns}
`
: ""}
${ICONS.clock}上次 ${fmtTime(a.last_check_at)}
${ICONS.refresh}每 ${plat.interval_seconds || (settings ? settings.global_interval_seconds : 300)}s
${a.last_error ? `
${ICONS.alert}${escapeHtml(a.last_error)}
` : ""}
${checkBtn}
`;
}
const STATUS_ORDER = { warn: 0, err: 1, pending: 2, ok: 3, disabled: 4 };
/* 切换某账号折线图的时间窗口;窗口数据按需拉取并缓存 */
async function setWindow(aid, win) {
accountWindows[aid] = win;
renderAccounts();
if (win === "all") return;
const key = aid + ":" + win;
if (windowCache[key]) return;
try {
const data = await api(`/accounts/${aid}/history?window=${win}&limit=120`);
windowCache[key] = data;
renderAccounts();
} catch (e) {
toast(e.message, "err");
}
}
/* 余额历史迷你折线图(SVG)。含阈值虚线;线色跟随状态。 */
function sparkline(hist, threshold, statusCls) {
const vals = (hist || []).map((h) => Number(h.balance)).filter(Number.isFinite);
if (vals.length === 0) return "";
const W = 100, H = 30, PAD = 3;
const max = Math.max(...vals, threshold > 0 ? threshold : 0);
const min = Math.min(...vals, threshold > 0 ? threshold : 0);
const range = max - min || 1;
const yOf = (v) => (H - PAD - ((v - min) / range) * (H - PAD * 2)).toFixed(1);
const lineColor = statusCls === "warn" ? "var(--warn)"
: statusCls === "err" ? "var(--text-3)"
: statusCls === "disabled" ? "var(--text-3)"
: "var(--ok)";
let thLine = "";
if (threshold > 0) {
const y = yOf(threshold);
thLine = ``;
}
if (vals.length === 1) {
const x = W / 2, y = yOf(vals[0]);
return ``;
}
const pts = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * W;
return `${x.toFixed(1)},${yOf(v)}`;
});
return ``;
}
function renderAccounts(animate = true) {
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;
// 同步平台筛选下拉
const sel = document.getElementById("filter-platform");
const cur = filterPlatform;
const opts = [''].concat(
platforms.map((p) => ``)
).join("");
if (sel.innerHTML !== opts) sel.innerHTML = opts;
if (!platforms.some((p) => p.id === cur)) filterPlatform = 0;
sel.value = filterPlatform;
// 筛选 + 排序(低于阈值/异常优先)
let list = accounts.filter((a) => {
if (filterPlatform !== 0 && a.platform_id !== filterPlatform) return false;
if (filterStatus === "all") return true;
return statusInfo(a).cls === filterStatus;
});
list = [...list].sort((x, y) => STATUS_ORDER[statusInfo(x).cls] - STATUS_ORDER[statusInfo(y).cls]);
grid.innerHTML = list.map((a, i) => accountCard(a, i, animate)).join("");
const hint = document.getElementById("empty-hint");
if (accounts.length > 0) {
hint.classList.add("hidden");
if (list.length === 0) {
grid.innerHTML = '没有符合条件的账号,试试调整筛选条件。
';
}
} else {
hint.classList.remove("hidden");
hint.innerHTML = platforms.length === 0
? "还没有平台。先到「平台」页添加一个平台,再回来添加账号。
"
: "还没有账号。点击右上角「+ 添加账号」开始监控。
";
}
}
/* ---------- 渲染:平台 ---------- */
function renderPlatforms(animate = true) {
const list = document.getElementById("platform-list");
if (platforms.length === 0) {
list.innerHTML = `暂无平台(代码内置平台会在启动时自动同步)。
`;
return;
}
list.innerHTML = platforms.map((p, i) => {
const b = brandStyle(p.icon || "");
const prov = providersList.find((x) => x.id === p.provider_id);
const animStyle = animate ? `animation-delay:${Math.min(i, 10) * 35}ms` : "animation:none";
return `
`;
}).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(`
`);
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("[data-ok]").onclick = () => { closeModal(mask); onOk(); };
}
/* 平台监控参数表单(提供方由代码内置,不可改) */
function platformForm(p) {
const v = p || {};
const prov = providersList.find((x) => x.id === v.provider_id);
openModal(`
${escapeHtml(v.name || "平台")} · 监控参数
`, (mask) => {
mask.querySelector("[data-cancel]").onclick = () => closeModal(mask);
mask.querySelector("#pf-save").onclick = async () => {
const payload = {
interval_seconds: numOrNull("#pf-interval"),
retry_count: numOrNull("#pf-retry"),
timeout_seconds: numOrNull("#pf-timeout"),
note: val("#pf-note"),
};
try {
await api("/platforms/" + v.id, { method: "PUT", body: JSON.stringify(payload) });
closeModal(mask);
toast("平台监控参数已更新");
await refresh();
renderPlatforms();
} catch (e) { err("#pf-error", e.message); }
};
});
}
/* 账号表单 */
function accountForm(a) {
const isEdit = !!a;
const v = a || {};
const opts = platforms.map((p) =>
``).join("");
openModal(`
${isEdit ? "编辑账号" : "添加账号"}
`, (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(`
余额历史 · ${escapeHtml(acc.name)}
加载中…
`);
try {
const hist = await api(`/accounts/${accountId}/history?limit=30`);
const body = mask.querySelector("#hist-body");
if (hist.length === 0) {
body.innerHTML = `暂无记录(检查成功后自动记录)
`;
return;
}
const max = Math.max(...hist.map((h) => h.balance), 1e-9);
const bars = hist.map((h) =>
``).join("");
const items = [...hist].reverse().map((h) =>
`${escapeHtml(h.checked_at)}${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}
`).join("");
body.innerHTML = `${bars}
${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(String(id).replace(/^#/, "")).value.trim(); }
function numOrNull(id) { const s = val(id); return s === "" ? null : Number(s); }
function err(id, msg) { document.getElementById(String(id).replace(/^#/, "")).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-account-btn").onclick = () => {
if (platforms.length === 0) {
toast("请先在「平台」页添加平台", "err");
switchView("platforms");
return;
}
accountForm(null);
};
document.getElementById("save-settings-btn").onclick = saveSettings;
document.getElementById("change-pwd-btn").onclick = changePassword;
document.getElementById("filter-pills").addEventListener("click", (e) => {
const pill = e.target.closest(".pill");
if (!pill) return;
filterStatus = pill.dataset.filter;
document.querySelectorAll("#filter-pills .pill").forEach((p) => p.classList.toggle("active", p === pill));
renderAccounts();
});
document.getElementById("filter-platform").addEventListener("change", (e) => {
filterPlatform = Number(e.target.value);
renderAccounts();
});
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 === "toggle") {
const checked = btn.checked;
try {
await api("/accounts/" + id, { method: "PUT", body: JSON.stringify({ enabled: checked }) });
toast(checked ? "账号已启用" : "账号已停用");
await refresh();
} catch (err2) { toast(err2.message, "err"); await refresh(); }
} 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") {
if (checkingIds.has(id)) return;
checkingIds.add(id);
renderAccounts();
const before = accounts.find((a) => a.id === id);
const prev = before ? before.last_check_at : null;
try {
await api(`/accounts/${id}/check`, { method: "POST" });
} catch (err2) {
toast(err2.message, "err");
}
// 轮询等待本次检查落库(last_check_at 变化),最长 120s
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2500));
try { await refresh(); } catch (_) { /* 忽略刷新错误 */ }
const acc = accounts.find((a) => a.id === id);
if (!acc || acc.last_check_at !== prev) break;
}
checkingIds.delete(id);
renderAccounts();
const acc = accounts.find((a) => a.id === id);
if (acc && acc.last_status === "ok") {
const cur = (platforms.find((p) => p.id === acc.platform_id) || {}).currency || "";
toast(`检查完成:余额 ${fmtBalance(acc.last_balance)} ${cur}`);
}
} else if (act === "history") {
showHistory(id);
} else if (act === "win") {
const wrap = btn.closest(".window-switch");
const aid = Number(wrap ? wrap.dataset.aid : 0);
if (aid) setWindow(aid, btn.dataset.win);
}
}
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"); }
}
}
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();
})();