+498
@@ -0,0 +1,498 @@
|
||||
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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}[char]));
|
||||
}
|
||||
Reference in New Issue
Block a user