From 78601aae5e0f36dc22f509aa8eeb0c1b96ce5629 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 10 Oct 2026 22:20:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BE=9B=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=8E=A5=E5=8F=A3=E4=BE=9B=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app/api.py | 72 ++++++- app/main.py | 14 +- app/schemas.py | 18 ++ web/app.js | 498 +++++++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 60 ++++++ web/styles.css | 359 +++++++++++++++++++++++++++++++++++ 说明.md | 1 + 部署.md | 2 + 8 files changed, 1020 insertions(+), 4 deletions(-) create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/styles.css diff --git a/app/api.py b/app/api.py index ca63aa6..5ce6d17 100644 --- a/app/api.py +++ b/app/api.py @@ -1,14 +1,17 @@ """对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。""" from datetime import date, timedelta +from decimal import Decimal from typing import Annotated from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload from app.clock import utcnow from app.database import get_db -from app.models import ComputeTask +from app.models import ComputeNode, ComputeTask, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus +from app.money import money, to_decimal from app.schemas import ( BalanceOut, ContributionOut, @@ -16,7 +19,9 @@ from app.schemas import ( HeartbeatOut, NodeCreate, NodeOut, + NodeView, SettleOut, + SummaryOut, TaskOut, TaskReportIn, UserCreate, @@ -25,6 +30,7 @@ from app.schemas import ( WithdrawalCreate, WithdrawalOut, WithdrawalReject, + WithdrawalView, YieldOut, ) from app.services.common import get_node_by_sn, get_user_by_wallet @@ -38,11 +44,51 @@ router = APIRouter(prefix="/api/v1") Db = Annotated[Session, Depends(get_db)] +@router.get("/summary", response_model=SummaryOut) +def summary(db: Db) -> SummaryOut: + users = db.scalar(select(func.count()).select_from(User)) or 0 + nodes = db.scalar(select(func.count()).select_from(ComputeNode)) or 0 + online = db.scalar( + select(func.count()).select_from(ComputeNode).where(ComputeNode.status == NodeStatus.ONLINE) + ) or 0 + authorized = _sum(db.scalars(select(ComputeNode.purchase_amount)).all()) + settled = _sum( + db.scalars(select(NodeYield.points_delta).where(NodeYield.status == YieldStatus.SETTLED)).all() + ) + pending = _sum( + db.scalars( + select(WithdrawalLog.amount).where(WithdrawalLog.status == WithdrawalStatus.PENDING) + ).all() + ) + return SummaryOut( + users=users, + nodes=nodes, + online=online, + offline=nodes - online, + authorized_amount=authorized, + settled_points=settled, + pending_withdrawals=pending, + ) + + +@router.get("/users", response_model=list[UserOut]) +def list_users(db: Db) -> list[User]: + return list(db.scalars(select(User).order_by(User.id.desc())).all()) + + @router.post("/users", response_model=UserOut) def create_user(body: UserCreate, db: Db) -> UserOut: return register_user(db, body.wallet_address) +@router.get("/nodes", response_model=list[NodeView]) +def list_nodes(db: Db) -> list[NodeView]: + rows = db.scalars( + select(ComputeNode).options(selectinload(ComputeNode.user)).order_by(ComputeNode.id.desc()) + ).all() + return [_node_view(row) for row in rows] + + @router.post("/nodes", response_model=NodeOut) def create_node(body: NodeCreate, db: Db) -> NodeOut: return register_node(db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url) @@ -99,6 +145,20 @@ def yields(sn: str, db: Db) -> list[YieldOut]: return list_node_yields(db, sn) +@router.get("/withdrawals", response_model=list[WithdrawalView]) +def list_withdrawals(db: Db) -> list[WithdrawalView]: + rows = db.scalars( + select(WithdrawalLog).options(selectinload(WithdrawalLog.user)).order_by(WithdrawalLog.id.desc()) + ).all() + return [ + WithdrawalView( + **WithdrawalOut.model_validate(row).model_dump(), + wallet_address=row.user.wallet_address, + ) + for row in rows + ] + + @router.get("/users/{wallet_address}/balance", response_model=BalanceOut) def balance(wallet_address: str, db: Db) -> BalanceOut: user = get_user_by_wallet(db, wallet_address) @@ -121,6 +181,14 @@ def reject(withdrawal_id: int, body: WithdrawalReject, db: Db) -> WithdrawalOut: return reject_withdrawal(db, withdrawal_id, body.note) +def _node_view(node: ComputeNode) -> NodeView: + return NodeView(**NodeOut.model_validate(node).model_dump(), wallet_address=node.user.wallet_address) + + +def _sum(values: list[object]) -> Decimal: + return money(sum((to_decimal(item) for item in values), Decimal("0"))) + + def _task_out(task: ComputeTask) -> TaskOut: return TaskOut( task_id=task.id, diff --git a/app/main.py b/app/main.py index b4255e3..dc91df6 100644 --- a/app/main.py +++ b/app/main.py @@ -4,9 +4,11 @@ from __future__ import annotations import logging from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles import app.models # noqa: F401 注册全部表 from app.api import router @@ -27,8 +29,16 @@ async def lifespan(_: FastAPI): stop_scheduler() -app = FastAPI(title="DePIN 算力节点与任务调度", version="1.0.0", lifespan=lifespan) +WEB_DIR = Path(__file__).resolve().parent.parent / "web" + +app = FastAPI(title="HyperNode-AI", version="1.0.0", lifespan=lifespan) app.include_router(router) +app.mount("/static", StaticFiles(directory=WEB_DIR), name="static") + + +@app.get("/") +def console() -> FileResponse: + return FileResponse(WEB_DIR / "index.html") @app.exception_handler(AppError) diff --git a/app/schemas.py b/app/schemas.py index 1838630..67fb7bc 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -49,6 +49,10 @@ class NodeOut(APIModel): callback_url: str | None +class NodeView(NodeOut): + wallet_address: str + + class HeartbeatIn(APIModel): sn: SN @@ -151,6 +155,20 @@ class WithdrawalOut(APIModel): updated_at: datetime +class WithdrawalView(WithdrawalOut): + wallet_address: str + + class BalanceOut(APIModel): wallet_address: str available_points: MoneyText + + +class SummaryOut(APIModel): + users: int + nodes: int + online: int + offline: int + authorized_amount: MoneyText + settled_points: MoneyText + pending_withdrawals: MoneyText diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..7ff2760 --- /dev/null +++ b/web/app.js @@ -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 ` +
+ ${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])); +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f47876b --- /dev/null +++ b/web/index.html @@ -0,0 +1,60 @@ + + + + + + HyperNode · 算力控制面 + + + + + + +
+ + +
+
+
+

控制面

+

总览

+
+
+
+ 北京时间 + --:--:-- +
+ +
+
+
+
+
+
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..32ffbf2 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,359 @@ +:root { + --bg: #08090d; + --ink: #f4efe6; + --muted: #a3988a; + --gold: #e0c07a; + --gold-deep: #a88345; + --line: rgba(224, 192, 122, 0.18); + --panel: rgba(16, 18, 24, 0.82); + --panel-solid: #12141b; + --online: #8ed0b5; + --offline: #d08a78; + --danger: #e09888; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.35); + --serif: "Fraunces", "Palatino Linotype", Palatino, serif; + --sans: "Manrope", "Segoe UI", sans-serif; + --mono: "IBM Plex Mono", ui-monospace, monospace; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + color: var(--ink); + font-family: var(--sans); + background: + radial-gradient(980px 520px at 0% -10%, rgba(224, 192, 122, 0.16), transparent 58%), + radial-gradient(760px 480px at 100% 0%, rgba(126, 164, 196, 0.12), transparent 52%), + var(--bg); +} +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(244, 239, 230, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(244, 239, 230, 0.035) 1px, transparent 1px); + background-size: 84px 84px; + mask-image: radial-gradient(circle at 20% 10%, #000 0%, transparent 72%); +} + +button, input, select { font: inherit; color: inherit; } +button { cursor: pointer; } +h1, h2, p { margin: 0; } + +.shell { + min-height: 100%; + display: grid; + grid-template-columns: 248px 1fr; +} + +.rail { + position: sticky; + top: 0; + height: 100vh; + padding: 28px 20px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + background: rgba(8, 9, 13, 0.72); + backdrop-filter: blur(16px); +} +.brand { + display: flex; + gap: 12px; + align-items: center; + color: inherit; + text-decoration: none; +} +.mark { + width: 44px; + height: 44px; + display: grid; + place-items: center; + color: var(--gold); + border: 1px solid var(--line); + border-radius: 14px; + background: linear-gradient(180deg, rgba(224, 192, 122, 0.16), transparent); +} +.brand strong { + display: block; + font-family: var(--serif); + font-size: 22px; + font-weight: 560; + letter-spacing: -0.03em; +} +.brand em { + display: block; + margin-top: 2px; + color: var(--muted); + font-style: normal; + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; +} +.nav { + display: grid; + gap: 6px; + margin-top: 36px; +} +.nav button { + text-align: left; + border: 0; + background: transparent; + color: var(--muted); + border-radius: 12px; + padding: 12px 14px; + letter-spacing: 0.04em; +} +.nav button.active, +.nav button:hover { + color: var(--ink); + background: rgba(224, 192, 122, 0.08); +} +.nav button.active { box-shadow: inset 2px 0 0 var(--gold); } +.rail-foot { + margin-top: auto; + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 13px; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); +} +.dot.on { + background: var(--online); + box-shadow: 0 0 0 4px rgba(142, 208, 181, 0.15); +} + +.stage { min-width: 0; padding: 32px 40px 56px; } +.top { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-end; + margin-bottom: 28px; +} +.eyebrow { + color: var(--gold); + font-size: 12px; + letter-spacing: 0.22em; + text-transform: uppercase; +} +h1 { + margin-top: 6px; + font-family: var(--serif); + font-size: 48px; + font-weight: 560; + letter-spacing: -0.04em; +} +.top-meta { display: flex; align-items: center; gap: 16px; } +.clock { + text-align: right; +} +.clock span { + display: block; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.14em; +} +.clock strong { + font-family: var(--mono); + font-size: 20px; + font-weight: 500; +} + +.ghost, .gold, .texty { + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; + padding: 10px 16px; +} +.ghost:hover, .texty:hover { border-color: var(--gold); } +.gold { + border-color: transparent; + background: linear-gradient(180deg, #f0d7a2, #c4964e); + color: #24180b; + font-weight: 700; +} +.gold:hover { filter: brightness(1.05); } +.texty { padding: 6px 10px; color: var(--gold); } + +.metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} +.metric, .panel, .table-wrap, .drawer { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 22px; + box-shadow: var(--shadow); +} +.metric { padding: 22px 22px 18px; } +.metric span { + color: var(--muted); + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.metric strong { + display: block; + margin-top: 14px; + font-family: var(--serif); + font-size: 40px; + font-weight: 560; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; +} +.metric em { + display: block; + margin-top: 8px; + color: var(--muted); + font-style: normal; + font-size: 13px; +} + +.layout, .forms { + display: grid; + gap: 16px; + margin-top: 16px; + align-items: start; +} +.layout { grid-template-columns: 1.4fr 0.8fr; } +.forms { grid-template-columns: 1fr 1fr; } +.panel { padding: 22px; } +.panel h2 { + font-family: var(--serif); + font-size: 28px; + font-weight: 560; + letter-spacing: -0.03em; +} +.sub { + margin: 6px 0 18px; + color: var(--muted); + font-size: 14px; +} +.stack { display: grid; gap: 14px; } +label { display: grid; gap: 6px; font-size: 13px; color: var(--muted); } +input, select { + width: 100%; + border: 1px solid var(--line); + background: rgba(8, 9, 13, 0.55); + border-radius: 12px; + padding: 12px 14px; + outline: none; +} +input:focus, select:focus { border-color: var(--gold); } +.row-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.table-wrap { overflow: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 14px 16px; text-align: left; border-bottom: 1px solid rgba(224, 192, 122, 0.08); font-size: 14px; } +th { + color: var(--muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} +td input { width: 148px; padding: 8px 10px; } +tr.clickable { cursor: pointer; } +tr.clickable:hover td { background: rgba(224, 192, 122, 0.04); } +.mono { font-family: var(--mono); font-size: 13px; } +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 4px 10px; + border: 1px solid var(--line); + font-size: 12px; +} +.pill i { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.pill.online { color: var(--online); } +.pill.offline, .pill.rejected, .pill.failed { color: var(--offline); } +.pill.pending { color: var(--gold); } +.pill.completed, .pill.settled { color: var(--online); } + +.empty { + padding: 48px 20px; + text-align: center; + color: var(--muted); +} +.toolbar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: end; + margin-bottom: 16px; +} +.toolbar .grow { min-width: 240px; flex: 1; } + +.mask { + position: fixed; + inset: 0; + background: rgba(5, 6, 8, 0.62); + display: grid; + justify-items: end; + z-index: 5; +} +.drawer { + width: min(460px, 100%); + height: 100%; + border-radius: 0; + border-right: 0; + padding: 28px; + overflow: auto; + background: #101218; +} +.drawer header { display: flex; justify-content: space-between; align-items: start; gap: 12px; } +.kv { display: grid; grid-template-columns: 88px 1fr; gap: 8px 12px; margin: 22px 0; font-size: 14px; } +.kv span { color: var(--muted); } + +.toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%) translateY(12px); + opacity: 0; + pointer-events: none; + background: #1b1e27; + border: 1px solid var(--line); + color: var(--ink); + padding: 12px 16px; + border-radius: 999px; + transition: 0.2s ease; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +@media (max-width: 1100px) { + .metrics, .forms { grid-template-columns: 1fr 1fr; } + .layout { grid-template-columns: 1fr; } +} +@media (max-width: 860px) { + .shell { grid-template-columns: 1fr; } + .rail { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .nav { grid-template-columns: repeat(4, 1fr); margin-top: 18px; } + .rail-foot { display: none; } + .stage { padding: 22px 16px 40px; } + h1 { font-size: 36px; } + .metrics, .layout, .forms { grid-template-columns: 1fr; } + .top { align-items: start; } +} diff --git a/说明.md b/说明.md index 3ff2e96..32978c9 100644 --- a/说明.md +++ b/说明.md @@ -23,6 +23,7 @@ python -m app | `app/jobs/daily_yield.py` | 凌晨清算入口 | | `app/scheduler.py` | 进程内定时任务 | | `app/settle.py` | 可单独执行的清算命令 | +| `web/` | 控制台页面,服务启动后打开 `/` | | `deploy/manage.sh` | Ubuntu 22.04 交互式部署,支持 `curl \| bash` | ## 数据表 diff --git a/部署.md b/部署.md index ef6b587..5ba96ae 100644 --- a/部署.md +++ b/部署.md @@ -60,6 +60,8 @@ journalctl -u hypernode.service -f curl http://127.0.0.1:33333/health ``` +控制台:`http://<服务器 IP>:33333/` + 接口文档:`http://<服务器 IP>:33333/docs` 手动补跑某一天的清算: