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("") : `| 还没有节点。下面可以登记第一台。 |
`}
+
+
+
+
+ `;
+}
+
+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]));
+}
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`
手动补跑某一天的清算: