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("") : `| 还没有节点。下面可以登记第一台。 |
`}
`;
}
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("") : `| 还没有提现申请。 |
`}
`;
}
function metric(label, value, note) {
return `${esc(label)}${esc(value ?? "0")}${esc(note || "")}`;
}
function userForm() {
return `
`;
}
function nodeForm() {
return `
`;
}
function walletList() {
const options = state.users.map((user) => ``).join("");
return ``;
}
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]));
}