Files
ai-balance-monitor/app/static/app.js
T

743 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = `<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;
}
/* 内联 SVG 小图标(lucide 风格描边) */
const ICONS = {
refresh: '<svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>',
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
pencil: '<svg viewBox="0 0 24 24"><path d="M17 3l4 4L8 20l-5 1 1-5L17 3z"/></svg>',
trash: '<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>',
alert: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>',
};
/* 容错 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 `<span class="badge" style="background:${color}">${escapeHtml(abbr)}</span>`;
}
return `<span class="badge" style="background:${color}"><span class="badge-icon">${svg}</span></span>`;
}
/* ---------- 视图/状态 ---------- */
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) =>
`<button class="win-btn ${win === k ? "active" : ""}" data-act="win" data-win="${k}" title="${WINDOW_LABELS[k]}">${k === "all" ? "ALL" : k.toUpperCase()}</button>`
).join("");
const checkBtn = checking
? `<button class="btn sm check-btn loading" disabled><span class="spinner"></span>检查中</button>`
: `<button class="btn sm check-btn" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled title=启用后可用" : ""}>${ICONS.refresh}立即检查</button>`;
return `
<div class="${cardCls}" style="${animStyle}">
<div class="card-head">
${badgeHtml(plat.icon || "", b.color, b.abbr)}
<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>
<span class="badge-pill ${st.cls}">${st.text}</span>
</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>`}
${th !== null ? `<span class="balance-vs">/ 阈值 ${th}</span>` : ""}
</div>
${spark
? `<div class="spark-wrap">
<div class="spark-title">余额历史 · ${WINDOW_LABELS[win]}${hist.length}${th !== null ? `,虚线阈值 ${th}` : ""}</div>
${spark}
<div class="window-switch" data-aid="${a.id}">${winBtns}</div>
</div>`
: ""}
<div class="card-meta">
<span class="meta-item">${ICONS.clock}上次 ${fmtTime(a.last_check_at)}</span>
<span class="meta-item">${ICONS.refresh}${plat.interval_seconds || (settings ? settings.global_interval_seconds : 300)}s</span>
</div>
${a.last_error ? `<div class="card-error" title="${escapeHtml(a.last_error)}">${ICONS.alert}${escapeHtml(a.last_error)}</div>` : ""}
<div class="card-actions">
${checkBtn}
<button class="btn sm icon-btn" data-act="history" data-id="${a.id}" title="余额历史">${ICONS.clock}</button>
<button class="btn sm icon-btn" data-act="edit" data-id="${a.id}" title="编辑账号">${ICONS.pencil}</button>
<button class="btn sm icon-btn danger" data-act="del" data-id="${a.id}" title="删除账号">${ICONS.trash}</button>
<label class="switch" title="${a.enabled ? "点击停用监控" : "点击启用监控"}">
<input type="checkbox" data-act="toggle" data-id="${a.id}" ${a.enabled ? "checked" : ""}>
<span class="slider"></span>
</label>
</div>
</div>`;
}
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 = `<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="var(--text-3)" stroke-width="1" stroke-dasharray="3 3" opacity="0.75"/>`;
}
if (vals.length === 1) {
const x = W / 2, y = yOf(vals[0]);
return `<svg class="sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">${thLine}<circle cx="${x}" cy="${y}" r="2.2" fill="${lineColor}" vector-effect="non-scaling-stroke"/></svg>`;
}
const pts = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * W;
return `${x.toFixed(1)},${yOf(v)}`;
});
return `<svg class="sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">${thLine}<polyline points="${pts.join(" ")}" fill="none" stroke="${lineColor}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/></svg>`;
}
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 = ['<option value="0">全部平台</option>'].concat(
platforms.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`)
).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 = '<div class="empty-filter">没有符合条件的账号,试试调整筛选条件。</div>';
}
} else {
hint.classList.remove("hidden");
hint.innerHTML = platforms.length === 0
? "<p>还没有平台。先到「平台」页添加一个平台,再回来添加账号。</p>"
: "<p>还没有账号。点击右上角「+ 添加账号」开始监控。</p>";
}
}
/* ---------- 渲染:平台 ---------- */
function renderPlatforms(animate = true) {
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 || "");
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 `
<div class="${rowCls}" style="${animStyle}">
${badgeHtml(p.icon || "", b.color, b.abbr)}
<div class="platform-info">
<div class="platform-name">${escapeHtml(p.name)}
${p.enabled ? "" : '<span class="badge-pill disabled">已停用</span>'}
</div>
<div class="platform-meta">
${prov ? escapeHtml(prov.name) : "未知提供方"} ·
${escapeHtml(p.currency)} ·
间隔 ${p.interval_seconds || "全局"}s ·
${p.account_count} 个账号
${prov && prov.description ? `<div style="color:var(--text-3)">${escapeHtml(prov.description)}</div>` : ""}
</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>
</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 v = p || {};
const prov = providersList.find((x) => x.id === v.provider_id);
openModal(`
<div class="modal">
<h3>${escapeHtml(v.name || "平台")} · 监控参数</h3>
<div class="form-grid">
<div class="form-row full">
<label>平台提供方(代码内置)</label>
<input value="${prov ? escapeHtml(prov.name + " · " + (prov.description || "")) : escapeHtml(v.provider_id || "")}" disabled>
</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="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 = {
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) =>
`<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(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();
})();