+70
-2
@@ -1,14 +1,17 @@
|
|||||||
"""对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。"""
|
"""对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。"""
|
||||||
|
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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.clock import utcnow
|
||||||
from app.database import get_db
|
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 (
|
from app.schemas import (
|
||||||
BalanceOut,
|
BalanceOut,
|
||||||
ContributionOut,
|
ContributionOut,
|
||||||
@@ -16,7 +19,9 @@ from app.schemas import (
|
|||||||
HeartbeatOut,
|
HeartbeatOut,
|
||||||
NodeCreate,
|
NodeCreate,
|
||||||
NodeOut,
|
NodeOut,
|
||||||
|
NodeView,
|
||||||
SettleOut,
|
SettleOut,
|
||||||
|
SummaryOut,
|
||||||
TaskOut,
|
TaskOut,
|
||||||
TaskReportIn,
|
TaskReportIn,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
@@ -25,6 +30,7 @@ from app.schemas import (
|
|||||||
WithdrawalCreate,
|
WithdrawalCreate,
|
||||||
WithdrawalOut,
|
WithdrawalOut,
|
||||||
WithdrawalReject,
|
WithdrawalReject,
|
||||||
|
WithdrawalView,
|
||||||
YieldOut,
|
YieldOut,
|
||||||
)
|
)
|
||||||
from app.services.common import get_node_by_sn, get_user_by_wallet
|
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)]
|
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)
|
@router.post("/users", response_model=UserOut)
|
||||||
def create_user(body: UserCreate, db: Db) -> UserOut:
|
def create_user(body: UserCreate, db: Db) -> UserOut:
|
||||||
return register_user(db, body.wallet_address)
|
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)
|
@router.post("/nodes", response_model=NodeOut)
|
||||||
def create_node(body: NodeCreate, db: Db) -> NodeOut:
|
def create_node(body: NodeCreate, db: Db) -> NodeOut:
|
||||||
return register_node(db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url)
|
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)
|
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)
|
@router.get("/users/{wallet_address}/balance", response_model=BalanceOut)
|
||||||
def balance(wallet_address: str, db: Db) -> BalanceOut:
|
def balance(wallet_address: str, db: Db) -> BalanceOut:
|
||||||
user = get_user_by_wallet(db, wallet_address)
|
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)
|
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:
|
def _task_out(task: ComputeTask) -> TaskOut:
|
||||||
return TaskOut(
|
return TaskOut(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
|
|||||||
+12
-2
@@ -4,9 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
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 注册全部表
|
import app.models # noqa: F401 注册全部表
|
||||||
from app.api import router
|
from app.api import router
|
||||||
@@ -27,8 +29,16 @@ async def lifespan(_: FastAPI):
|
|||||||
stop_scheduler()
|
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.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)
|
@app.exception_handler(AppError)
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ class NodeOut(APIModel):
|
|||||||
callback_url: str | None
|
callback_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class NodeView(NodeOut):
|
||||||
|
wallet_address: str
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatIn(APIModel):
|
class HeartbeatIn(APIModel):
|
||||||
sn: SN
|
sn: SN
|
||||||
|
|
||||||
@@ -151,6 +155,20 @@ class WithdrawalOut(APIModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WithdrawalView(WithdrawalOut):
|
||||||
|
wallet_address: str
|
||||||
|
|
||||||
|
|
||||||
class BalanceOut(APIModel):
|
class BalanceOut(APIModel):
|
||||||
wallet_address: str
|
wallet_address: str
|
||||||
available_points: MoneyText
|
available_points: MoneyText
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryOut(APIModel):
|
||||||
|
users: int
|
||||||
|
nodes: int
|
||||||
|
online: int
|
||||||
|
offline: int
|
||||||
|
authorized_amount: MoneyText
|
||||||
|
settled_points: MoneyText
|
||||||
|
pending_withdrawals: MoneyText
|
||||||
|
|||||||
+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]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>HyperNode · 算力控制面</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,560;9..144,650&family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/static/styles.css?v=2" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="rail">
|
||||||
|
<a class="brand" href="#overview">
|
||||||
|
<span class="mark" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 32 32" width="28" height="28">
|
||||||
|
<path d="M16 3 L27 9.5 V22.5 L16 29 L5 22.5 V9.5 Z" fill="none" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<circle cx="16" cy="16" r="2.2" fill="currentColor"/>
|
||||||
|
<path d="M16 8 V14 M16 18 V24 M10 12.5 L14.2 15 M17.8 17 L22 19.5" stroke="currentColor" stroke-width="1.2"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>HyperNode</strong>
|
||||||
|
<em>Compute Atelier</em>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav" id="nav">
|
||||||
|
<button type="button" data-view="overview">总览</button>
|
||||||
|
<button type="button" data-view="nodes">节点</button>
|
||||||
|
<button type="button" data-view="ledger">流水</button>
|
||||||
|
<button type="button" data-view="cash">提现</button>
|
||||||
|
</nav>
|
||||||
|
<div class="rail-foot">
|
||||||
|
<span id="health-dot" class="dot"></span>
|
||||||
|
<span id="health-text">正在连接</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<header class="top">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow" id="eyebrow">控制面</p>
|
||||||
|
<h1 id="title">总览</h1>
|
||||||
|
</div>
|
||||||
|
<div class="top-meta">
|
||||||
|
<div class="clock">
|
||||||
|
<span>北京时间</span>
|
||||||
|
<strong id="clock">--:--:--</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ghost" id="refresh">刷新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="view" class="view"></main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="toast" class="toast" role="status"></div>
|
||||||
|
<script src="/static/app.js?v=2"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+359
@@ -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; }
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ python -m app
|
|||||||
| `app/jobs/daily_yield.py` | 凌晨清算入口 |
|
| `app/jobs/daily_yield.py` | 凌晨清算入口 |
|
||||||
| `app/scheduler.py` | 进程内定时任务 |
|
| `app/scheduler.py` | 进程内定时任务 |
|
||||||
| `app/settle.py` | 可单独执行的清算命令 |
|
| `app/settle.py` | 可单独执行的清算命令 |
|
||||||
|
| `web/` | 控制台页面,服务启动后打开 `/` |
|
||||||
| `deploy/manage.sh` | Ubuntu 22.04 交互式部署,支持 `curl \| bash` |
|
| `deploy/manage.sh` | Ubuntu 22.04 交互式部署,支持 `curl \| bash` |
|
||||||
|
|
||||||
## 数据表
|
## 数据表
|
||||||
|
|||||||
Reference in New Issue
Block a user