/* 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 }; } /* 品牌图标 badge:有内嵌 SVG 显示真实图标,否则品牌色 + 缩写 */ function badgeHtml(iconKey, color, abbr) { const svg = BRAND_ICONS[iconKey]; if (!svg) { return `${escapeHtml(abbr)}`; } return `${svg}`; } /* ---------- 视图/状态 ---------- */ 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" : "", animate ? "" : "no-anim", ].join(" ").trim(); const animStyle = animate ? `animation-delay:${Math.min(idx, 12) * 40}ms` : ""; 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 `
${badgeHtml(plat.icon || "", b.color, 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 `${thLine}`; } const pts = vals.map((v, i) => { const x = (i / (vals.length - 1)) * W; return `${x.toFixed(1)},${yOf(v)}`; }); return `${thLine}`; } 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` : ""; const rowCls = `platform-row ${p.enabled ? "" : "platform-off"}${animate ? "" : " no-anim"}`; return `
${badgeHtml(p.icon || "", b.color, b.abbr)}
${escapeHtml(p.name)} ${p.enabled ? "" : '已停用'}
${prov ? escapeHtml(prov.name) : "未知提供方"} · ${escapeHtml(p.currency)} · 间隔 ${p.interval_seconds || "全局"}s · ${p.account_count} 个账号 ${prov && prov.description ? `
${escapeHtml(prov.description)}
` : ""}
`; }).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(` `, (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(` `, (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(` `); 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(); })();