feat: AI 余额监控服务(平台/账号 CRUD、并发轮询、Telegram 阈值提醒)
This commit is contained in:
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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>Headers(JSON,值支持 {{apiKey}})</label><textarea id="pf-headers">${escapeHtml(headersStr)}</textarea></div>
|
||||
<div class="form-row full"><label>Body(POST 时使用,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();
|
||||
})();
|
||||
Reference in New Issue
Block a user