const viewRoot = document.querySelector("#view"); const titleEl = document.querySelector("#title"); const toastEl = document.querySelector("#toast"); const state = { view: "overview", summary: null, nodes: [], users: [], withdrawals: [], yields: [], selected: "", }; const copy = { overview: "总览", nodes: "节点", ledger: "流水", cash: "提现", }; document.querySelectorAll("#nav button").forEach((button) => { button.addEventListener("click", () => go(button.dataset.view)); }); document.querySelector("#refresh").addEventListener("click", () => load().catch(showError)); window.addEventListener("hashchange", syncFromHash); tick(); setInterval(tick, 1000); syncFromHash(); load().catch(showError); probeHealth(); function tick() { document.querySelector("#clock").textContent = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).format(new Date()); } function go(view) { location.hash = view; } async function syncFromHash() { const next = (location.hash || "#overview").slice(1); state.view = copy[next] ? next : "overview"; document.querySelectorAll("#nav button").forEach((button) => { button.classList.toggle("active", button.dataset.view === state.view); }); titleEl.textContent = copy[state.view]; if (state.view === "ledger" && state.selected) { try { state.yields = await api(`/nodes/${encodeURIComponent(state.selected)}/yields`); } catch (error) { showError(error); } } render(); } async function load() { const [summary, nodes, users, withdrawals] = await Promise.all([ api("/summary"), api("/nodes"), api("/users"), api("/withdrawals"), ]); state.summary = summary; state.nodes = nodes; state.users = users; state.withdrawals = withdrawals; if (!state.selected && nodes[0]) state.selected = nodes[0].sn; if (state.view === "ledger" && state.selected) { state.yields = await api(`/nodes/${encodeURIComponent(state.selected)}/yields`); } render(); } async function probeHealth() { const dot = document.querySelector("#health-dot"); const text = document.querySelector("#health-text"); try { const res = await fetch("/health"); if (!res.ok) throw new Error("offline"); dot.classList.add("on"); text.textContent = "服务在线"; } catch { text.textContent = "服务未响应"; } } function render() { const painters = { overview: paintOverview, nodes: paintNodes, ledger: paintLedger, cash: paintCash }; viewRoot.innerHTML = painters[state.view](); bind(); } function paintOverview() { const s = state.summary || {}; return `
${metric("授权金额", money(s.authorized_amount), `${s.nodes || 0} 台节点`)} ${metric("在线", `${s.online || 0}`, `离线 ${s.offline || 0}`)} ${metric("已入账积分", money(s.settled_points), `${s.users || 0} 个钱包`)} ${metric("待处理提现", money(s.pending_withdrawals), "占用可用余额")}
${state.nodes.length ? state.nodes.slice(0, 8).map(nodeRow).join("") : ``}
节点钱包购买金额状态到期
还没有节点。下面可以登记第一台。
${userForm()} ${nodeForm()}
`; } function paintNodes() { return `
${state.nodes.length ? state.nodes.map(nodeRow).join("") : ``}
序列号钱包购买金额状态到期
节点列表是空的。
${nodeForm()}
`; } function paintLedger() { const options = state.nodes.map((node) => ``).join(""); return `
${state.yields.length ? state.yields.map(yieldRow).join("") : ``}
日期积分算力产出在线率响应率表现状态
这一天还没有入账记录。
`; } function paintCash() { return `
${state.withdrawals.length ? state.withdrawals.map(cashRow).join("") : ``}
单号钱包金额状态
还没有提现申请。

可用积分

已入账积分减去待处理和已完成的提现。

${walletList()}

申请提现

提交后金额会被占用,确认时再写入链上哈希。

`; } function metric(label, value, note) { return `
${esc(label)}${esc(value ?? "0")}${esc(note || "")}
`; } function userForm() { return `

登记钱包

一个钱包可以绑定多台节点。

`; } function nodeForm() { return `

登记节点

授权周期 12 个月,购买金额按天释放。

${walletList()}
`; } function walletList() { const options = state.users.map((user) => ``).join(""); return `${options}`; } function nodeRow(node) { return ` ${esc(node.sn)} ${esc(short(node.wallet_address))} ${esc(money(node.purchase_amount))} ${pill(node.status)} ${esc(day(node.license_expires_at))} `; } function yieldRow(row) { return ` ${esc(row.yield_date)} ${esc(money(row.points_delta))} ${esc(money(row.compute_output))} ${esc(pct(row.online_rate))} ${esc(pct(row.task_response_rate))} ${esc(pct(row.performance))} ${pill(row.status)} `; } function cashRow(row) { const actions = row.status === "pending" ? `
` : `${esc(row.tx_hash || "—")}`; return ` #${row.id} ${esc(short(row.wallet_address))} ${esc(money(row.amount))} ${esc(row.asset_symbol)} ${pill(row.status)} ${actions} `; } function pill(status) { const label = { online: "在线", offline: "离线", pending: "待处理", completed: "已完成", rejected: "已拒绝", failed: "失败", settled: "已入账", void: "作废", }[status] || status; return `${esc(label)}`; } function bind() { const userFormEl = document.querySelector("#user-form"); const nodeFormEl = document.querySelector("#node-form"); const balanceForm = document.querySelector("#balance-form"); const withdrawForm = document.querySelector("#withdraw-form"); if (userFormEl) userFormEl.addEventListener("submit", onCreateUser); if (nodeFormEl) nodeFormEl.addEventListener("submit", onCreateNode); if (balanceForm) balanceForm.addEventListener("submit", onBalance); if (withdrawForm) withdrawForm.addEventListener("submit", onWithdraw); document.querySelectorAll("tr.clickable").forEach((row) => row.addEventListener("click", () => openNode(row.dataset.sn))); const ledgerNode = document.querySelector("#ledger-node"); if (ledgerNode) { ledgerNode.addEventListener("change", async () => { state.selected = ledgerNode.value; state.yields = state.selected ? await api(`/nodes/${encodeURIComponent(state.selected)}/yields`) : []; render(); }); } const settle = document.querySelector("#settle"); if (settle) settle.addEventListener("click", onSettle); document.querySelectorAll("[data-confirm]").forEach((button) => button.addEventListener("click", () => onConfirm(button.dataset.confirm))); document.querySelectorAll("[data-reject]").forEach((button) => button.addEventListener("click", () => onReject(button.dataset.reject))); } async function onCreateUser(event) { event.preventDefault(); const body = Object.fromEntries(new FormData(event.target)); await run(() => api("/users", { method: "POST", body: JSON.stringify(body) }), "钱包已登记"); } async function onCreateNode(event) { event.preventDefault(); const body = Object.fromEntries(new FormData(event.target)); if (!body.callback_url) delete body.callback_url; await run(() => api("/nodes", { method: "POST", body: JSON.stringify(body) }), "节点已登记"); } async function onBalance(event) { event.preventDefault(); const wallet = new FormData(event.target).get("wallet_address"); const data = await api(`/users/${encodeURIComponent(wallet)}/balance`); document.querySelector("#balance-result").textContent = `${data.wallet_address} 可用 ${money(data.available_points)}`; } async function onWithdraw(event) { event.preventDefault(); const body = Object.fromEntries(new FormData(event.target)); await run(() => api("/withdrawals", { method: "POST", body: JSON.stringify(body) }), "提现申请已提交"); } async function onSettle() { try { const date = document.querySelector("#settle-date").value; const query = date ? `?yield_date=${date}` : ""; const result = await api(`/yield/settle${query}`, { method: "POST" }); showToast(`已清算 ${result.yield_date},新增 ${result.created} 条,积分 ${money(result.total_points)}`); await load(); } catch (error) { showError(error); } } async function onConfirm(id) { const hash = document.querySelector(`[data-hash="${id}"]`).value.trim(); if (!hash) return showToast("请先填写交易哈希"); await run(() => api(`/withdrawals/${id}/confirm`, { method: "POST", body: JSON.stringify({ tx_hash: hash }) }), "提现已确认"); } async function onReject(id) { await run(() => api(`/withdrawals/${id}/reject`, { method: "POST", body: JSON.stringify({ note: "控制台拒绝" }) }), "提现已拒绝"); } async function openNode(sn) { const node = state.nodes.find((item) => item.sn === sn); if (!node) return; let yields = []; try { yields = await api(`/nodes/${encodeURIComponent(sn)}/yields`); } catch (error) { showError(error); } const mask = document.createElement("div"); mask.className = "mask"; mask.innerHTML = ` `; mask.addEventListener("click", (event) => { if (event.target === mask || event.target.closest("[data-close]")) mask.remove(); }); mask.querySelector("#beat").addEventListener("click", async () => { await run(() => api("/node/heartbeat", { method: "POST", body: JSON.stringify({ sn }) }), "心跳已记录"); mask.remove(); }); document.body.appendChild(mask); } async function run(action, message) { try { await action(); showToast(message); await load(); } catch (error) { showError(error); } } async function api(path, options = {}) { const res = await fetch(`/api/v1${path}`, { ...options, headers: { "Content-Type": "application/json", ...(options.headers || {}) }, }); const text = await res.text(); const data = text ? JSON.parse(text) : null; if (!res.ok) throw new Error(errorText(data)); return data; } function errorText(data) { const detail = data && data.detail; if (typeof detail === "string") return detail; if (Array.isArray(detail)) return detail.map((item) => item.msg).join(";"); return "请求失败"; } function showToast(message) { toastEl.textContent = message; toastEl.classList.add("show"); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toastEl.classList.remove("show"), 2600); } function showError(error) { showToast(error.message || "操作失败"); } function money(value) { if (value == null || value === "") return "0"; const raw = String(value); const neg = raw.startsWith("-"); const [whole, frac = ""] = raw.replace("-", "").split("."); const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ","); const trimmed = frac.replace(/0+$/, ""); return `${neg ? "-" : ""}${trimmed ? `${grouped}.${trimmed}` : grouped}`; } function pct(value) { const number = Number(value); if (!Number.isFinite(number)) return "—"; return `${(number * 100).toFixed(1)}%`; } function when(value) { if (!value) return "—"; return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).format(new Date(value)); } function day(value) { if (!value) return "—"; return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(value)); } function short(value) { const text = String(value || ""); return text.length > 16 ? `${text.slice(0, 8)}…${text.slice(-4)}` : text; } function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[char])); }