Files
HyperNode-AI/web/app.js
T
2026-10-10 22:20:45 +08:00

499 lines
17 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.
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 `
<section class="metrics">
${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), "占用可用余额")}
</section>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead><tr><th>节点</th><th>钱包</th><th>购买金额</th><th>状态</th><th>到期</th></tr></thead>
<tbody>
${state.nodes.length ? state.nodes.slice(0, 8).map(nodeRow).join("") : `<tr><td colspan="5" class="empty">还没有节点。下面可以登记第一台。</td></tr>`}
</tbody>
</table>
</div>
<section class="forms">
${userForm()}
${nodeForm()}
</section>
`;
}
function paintNodes() {
return `
<section class="layout">
<div class="table-wrap">
<table>
<thead><tr><th>序列号</th><th>钱包</th><th>购买金额</th><th>状态</th><th>到期</th></tr></thead>
<tbody>
${state.nodes.length ? state.nodes.map(nodeRow).join("") : `<tr><td colspan="5" class="empty">节点列表是空的。</td></tr>`}
</tbody>
</table>
</div>
${nodeForm()}
</section>
`;
}
function paintLedger() {
const options = state.nodes.map((node) => `<option value="${esc(node.sn)}" ${node.sn === state.selected ? "selected" : ""}>${esc(node.sn)}</option>`).join("");
return `
<div class="toolbar">
<label class="grow">节点
<select id="ledger-node">${options || `<option value="">暂无节点</option>`}</select>
</label>
<label>清算日期
<input id="settle-date" type="date" />
</label>
<button class="gold" id="settle" type="button">清算</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>日期</th><th>积分</th><th>算力产出</th><th>在线率</th><th>响应率</th><th>表现</th><th>状态</th></tr></thead>
<tbody>
${state.yields.length ? state.yields.map(yieldRow).join("") : `<tr><td colspan="7" class="empty">这一天还没有入账记录。</td></tr>`}
</tbody>
</table>
</div>
`;
}
function paintCash() {
return `
<section class="layout">
<div class="table-wrap">
<table>
<thead><tr><th>单号</th><th>钱包</th><th>金额</th><th>状态</th><th></th></tr></thead>
<tbody>
${state.withdrawals.length ? state.withdrawals.map(cashRow).join("") : `<tr><td colspan="5" class="empty">还没有提现申请。</td></tr>`}
</tbody>
</table>
</div>
<div class="stack">
<form class="panel" id="balance-form">
<h2>可用积分</h2>
<p class="sub">已入账积分减去待处理和已完成的提现。</p>
<label>钱包
<input name="wallet_address" list="wallets" required placeholder="0x..." />
</label>
<div class="row-actions" style="margin-top:14px">
<button class="gold" type="submit">查询</button>
</div>
<p class="sub" id="balance-result"></p>
${walletList()}
</form>
<form class="panel" id="withdraw-form">
<h2>申请提现</h2>
<p class="sub">提交后金额会被占用,确认时再写入链上哈希。</p>
<div class="stack">
<label>钱包<input name="wallet_address" list="wallets" required /></label>
<label>金额<input name="amount" required placeholder="1.25" /></label>
<label>资产<input name="asset_symbol" value="POINT" /></label>
<button class="gold" type="submit">提交申请</button>
</div>
</form>
</div>
</section>
`;
}
function metric(label, value, note) {
return `<article class="metric"><span>${esc(label)}</span><strong>${esc(value ?? "0")}</strong><em>${esc(note || "")}</em></article>`;
}
function userForm() {
return `
<form class="panel" id="user-form">
<h2>登记钱包</h2>
<p class="sub">一个钱包可以绑定多台节点。</p>
<label>钱包地址<input name="wallet_address" required minlength="4" placeholder="0xabc123" /></label>
<div class="row-actions" style="margin-top:14px"><button class="gold" type="submit">创建用户</button></div>
</form>
`;
}
function nodeForm() {
return `
<form class="panel" id="node-form">
<h2>登记节点</h2>
<p class="sub">授权周期 12 个月,购买金额按天释放。</p>
${walletList()}
<div class="stack">
<label>钱包<input name="wallet_address" list="wallets" required /></label>
<label>序列号<input name="sn" required minlength="4" placeholder="NODE-001" /></label>
<label>购买金额<input name="purchase_amount" required placeholder="365" /></label>
<label>回调地址<input name="callback_url" placeholder="https:// 可选" /></label>
<button class="gold" type="submit">创建节点</button>
</div>
</form>
`;
}
function walletList() {
const options = state.users.map((user) => `<option value="${esc(user.wallet_address)}"></option>`).join("");
return `<datalist id="wallets">${options}</datalist>`;
}
function nodeRow(node) {
return `<tr class="clickable" role="button" tabindex="0" data-sn="${esc(node.sn)}">
<td class="mono">${esc(node.sn)}</td>
<td class="mono">${esc(short(node.wallet_address))}</td>
<td>${esc(money(node.purchase_amount))}</td>
<td>${pill(node.status)}</td>
<td>${esc(day(node.license_expires_at))}</td>
</tr>`;
}
function yieldRow(row) {
return `<tr>
<td class="mono">${esc(row.yield_date)}</td>
<td>${esc(money(row.points_delta))}</td>
<td>${esc(money(row.compute_output))}</td>
<td>${esc(pct(row.online_rate))}</td>
<td>${esc(pct(row.task_response_rate))}</td>
<td>${esc(pct(row.performance))}</td>
<td>${pill(row.status)}</td>
</tr>`;
}
function cashRow(row) {
const actions = row.status === "pending"
? `<div class="row-actions">
<input data-hash="${row.id}" placeholder="交易哈希" />
<button class="texty" data-confirm="${row.id}" type="button">确认</button>
<button class="texty" data-reject="${row.id}" type="button">拒绝</button>
</div>`
: `<span class="mono">${esc(row.tx_hash || "—")}</span>`;
return `<tr>
<td class="mono">#${row.id}</td>
<td class="mono">${esc(short(row.wallet_address))}</td>
<td>${esc(money(row.amount))} ${esc(row.asset_symbol)}</td>
<td>${pill(row.status)}</td>
<td>${actions}</td>
</tr>`;
}
function pill(status) {
const label = {
online: "在线",
offline: "离线",
pending: "待处理",
completed: "已完成",
rejected: "已拒绝",
failed: "失败",
settled: "已入账",
void: "作废",
}[status] || status;
return `<span class="pill ${esc(status)}"><i></i>${esc(label)}</span>`;
}
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 = `
<aside class="drawer">
<header>
<div>
<p class="eyebrow">节点</p>
<h2>${esc(node.sn)}</h2>
</div>
<button class="ghost" type="button" data-close>关闭</button>
</header>
<div style="margin-top:16px">${pill(node.status)}</div>
<div class="kv">
<span>钱包</span><b class="mono">${esc(node.wallet_address)}</b>
<span>购买</span><b>${esc(money(node.purchase_amount))}</b>
<span>开始</span><b>${esc(when(node.licensed_at))}</b>
<span>到期</span><b>${esc(when(node.license_expires_at))}</b>
<span>心跳</span><b>${esc(when(node.last_heartbeat_at))}</b>
</div>
<button class="gold" type="button" id="beat">发送心跳</button>
<h2 style="margin-top:28px">最近流水</h2>
<div class="table-wrap" style="margin-top:12px;box-shadow:none">
<table>
<thead><tr><th>日期</th><th>积分</th><th>在线率</th></tr></thead>
<tbody>${yields.slice(0, 6).map((row) => `<tr><td class="mono">${esc(row.yield_date)}</td><td>${esc(money(row.points_delta))}</td><td>${esc(pct(row.online_rate))}</td></tr>`).join("") || `<tr><td colspan="3" class="empty">暂无流水</td></tr>`}</tbody>
</table>
</div>
</aside>
`;
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[char]));
}