加上登录和角色权限,并用 Vue 控制台区分用户与管理员。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,3 +5,4 @@ venv/
|
|||||||
.env
|
.env
|
||||||
*.db
|
*.db
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
node_modules/
|
||||||
|
|||||||
+172
-61
@@ -5,25 +5,34 @@ from decimal import Decimal
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import case, func, select
|
||||||
from sqlalchemy.orm import Session, selectinload
|
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 ComputeNode, ComputeTask, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus
|
from app.deps import AdminUser, CurrentUser
|
||||||
from app.money import money, to_decimal
|
from app.errors import AppError
|
||||||
|
from app.models import ComputeNode, ComputeTask, User, UserRole, WithdrawalLog, WithdrawalStatus
|
||||||
|
from app.money import money
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
|
AdminUserOut,
|
||||||
BalanceOut,
|
BalanceOut,
|
||||||
ContributionOut,
|
ContributionOut,
|
||||||
|
DashboardOut,
|
||||||
HeartbeatIn,
|
HeartbeatIn,
|
||||||
HeartbeatOut,
|
HeartbeatOut,
|
||||||
|
LoginIn,
|
||||||
|
MeNodeCreate,
|
||||||
|
MeWithdrawalCreate,
|
||||||
NodeCreate,
|
NodeCreate,
|
||||||
NodeOut,
|
NodeOut,
|
||||||
NodeView,
|
NodeView,
|
||||||
|
RegisterIn,
|
||||||
SettleOut,
|
SettleOut,
|
||||||
SummaryOut,
|
SummaryOut,
|
||||||
TaskOut,
|
TaskOut,
|
||||||
TaskReportIn,
|
TaskReportIn,
|
||||||
|
TokenOut,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserOut,
|
UserOut,
|
||||||
WithdrawalConfirm,
|
WithdrawalConfirm,
|
||||||
@@ -33,8 +42,18 @@ from app.schemas import (
|
|||||||
WithdrawalView,
|
WithdrawalView,
|
||||||
YieldOut,
|
YieldOut,
|
||||||
)
|
)
|
||||||
|
from app.security import create_access_token
|
||||||
|
from app.services.auth import authenticate, register_account
|
||||||
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
|
||||||
from app.services.heartbeat import record_heartbeat
|
from app.services.heartbeat import record_heartbeat
|
||||||
|
from app.services.portal import (
|
||||||
|
authorized_amount,
|
||||||
|
live_nodes,
|
||||||
|
node_counts,
|
||||||
|
pending_withdrawal_amount,
|
||||||
|
settled_points,
|
||||||
|
user_rows,
|
||||||
|
)
|
||||||
from app.services.registry import register_node, register_user
|
from app.services.registry import register_node, register_user
|
||||||
from app.services.tasks import list_pending_tasks, report_task
|
from app.services.tasks import list_pending_tasks, report_task
|
||||||
from app.services.withdrawals import available_balance, confirm_withdrawal, reject_withdrawal, request_withdrawal
|
from app.services.withdrawals import available_balance, confirm_withdrawal, reject_withdrawal, request_withdrawal
|
||||||
@@ -44,35 +63,90 @@ router = APIRouter(prefix="/api/v1")
|
|||||||
Db = Annotated[Session, Depends(get_db)]
|
Db = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/register", response_model=TokenOut)
|
||||||
|
def register(body: RegisterIn, db: Db) -> TokenOut:
|
||||||
|
user = register_account(db, body.wallet_address, body.username, body.password)
|
||||||
|
return _token(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/login", response_model=TokenOut)
|
||||||
|
def login(body: LoginIn, db: Db) -> TokenOut:
|
||||||
|
user = authenticate(db, body.wallet_address, body.username, body.password)
|
||||||
|
return _token(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
def me(user: CurrentUser) -> User:
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/dashboard", response_model=DashboardOut)
|
||||||
|
def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
|
||||||
|
nodes = live_nodes(db, user.id)
|
||||||
|
withdrawals = db.scalars(
|
||||||
|
select(WithdrawalLog).where(WithdrawalLog.user_id == user.id).order_by(WithdrawalLog.id.desc()).limit(20)
|
||||||
|
).all()
|
||||||
|
today = money(sum((item["today_points"] for item in nodes), Decimal("0")))
|
||||||
|
return DashboardOut(
|
||||||
|
wallet_address=user.wallet_address,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
available_points=available_balance(db, user.id),
|
||||||
|
settled_points=settled_points(db, user.id),
|
||||||
|
today_points=today,
|
||||||
|
nodes=[NodeView.model_validate(item) for item in nodes],
|
||||||
|
withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/nodes", response_model=NodeOut)
|
||||||
|
def create_my_node(body: MeNodeCreate, user: CurrentUser, db: Db) -> NodeOut:
|
||||||
|
return register_node(db, user.wallet_address, body.sn, body.purchase_amount, body.callback_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/withdrawals", response_model=WithdrawalOut)
|
||||||
|
def create_my_withdrawal(body: MeWithdrawalCreate, user: CurrentUser, db: Db) -> WithdrawalOut:
|
||||||
|
row, remaining = request_withdrawal(db, user.wallet_address, body.amount, body.asset_symbol)
|
||||||
|
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/summary", response_model=SummaryOut)
|
||||||
|
def admin_summary(_: AdminUser, db: Db) -> SummaryOut:
|
||||||
|
return _summary(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users", response_model=list[AdminUserOut])
|
||||||
|
def admin_users(_: AdminUser, db: Db) -> list[dict]:
|
||||||
|
return user_rows(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/nodes", response_model=list[NodeView])
|
||||||
|
def admin_nodes(_: AdminUser, db: Db) -> list[NodeView]:
|
||||||
|
return [NodeView.model_validate(item) for item in live_nodes(db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/withdrawals", response_model=list[WithdrawalView])
|
||||||
|
def admin_withdrawals(_: AdminUser, db: Db) -> list[WithdrawalView]:
|
||||||
|
return [_withdrawal_view(row) for row in _withdrawal_rows(db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
|
||||||
|
def admin_confirm(withdrawal_id: int, body: WithdrawalConfirm, _: AdminUser, db: Db) -> WithdrawalOut:
|
||||||
|
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
|
||||||
|
def admin_reject(withdrawal_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> WithdrawalOut:
|
||||||
|
return reject_withdrawal(db, withdrawal_id, body.note)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=SummaryOut)
|
@router.get("/summary", response_model=SummaryOut)
|
||||||
def summary(db: Db) -> SummaryOut:
|
def summary(_: AdminUser, db: Db) -> SummaryOut:
|
||||||
users = db.scalar(select(func.count()).select_from(User)) or 0
|
return _summary(db)
|
||||||
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])
|
@router.get("/users", response_model=list[UserOut])
|
||||||
def list_users(db: Db) -> list[User]:
|
def list_users(_: AdminUser, db: Db) -> list[User]:
|
||||||
return list(db.scalars(select(User).order_by(User.id.desc())).all())
|
return list(db.scalars(select(User).order_by(User.id.desc())).all())
|
||||||
|
|
||||||
|
|
||||||
@@ -82,21 +156,19 @@ def create_user(body: UserCreate, db: Db) -> UserOut:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/nodes", response_model=list[NodeView])
|
@router.get("/nodes", response_model=list[NodeView])
|
||||||
def list_nodes(db: Db) -> list[NodeView]:
|
def list_nodes(_: AdminUser, db: Db) -> list[NodeView]:
|
||||||
rows = db.scalars(
|
return [NodeView.model_validate(item) for item in live_nodes(db)]
|
||||||
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, user: CurrentUser, db: Db) -> NodeOut:
|
||||||
|
_must_own_wallet(user, body.wallet_address)
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nodes/{sn}", response_model=NodeOut)
|
@router.get("/nodes/{sn}", response_model=NodeOut)
|
||||||
def get_node(sn: str, db: Db) -> NodeOut:
|
def get_node(sn: str, user: CurrentUser, db: Db) -> NodeOut:
|
||||||
return get_node_by_sn(db, sn)
|
return _owned_node(db, user, sn)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/node/heartbeat", response_model=HeartbeatOut)
|
@router.post("/node/heartbeat", response_model=HeartbeatOut)
|
||||||
@@ -121,14 +193,14 @@ def report(body: TaskReportIn, db: Db) -> TaskOut:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/nodes/{sn}/contribution", response_model=ContributionOut)
|
@router.get("/nodes/{sn}/contribution", response_model=ContributionOut)
|
||||||
def contribution(sn: str, yield_date: date, db: Db) -> ContributionOut:
|
def contribution(sn: str, yield_date: date, user: CurrentUser, db: Db) -> ContributionOut:
|
||||||
node = get_node_by_sn(db, sn)
|
node = _owned_node(db, user, sn)
|
||||||
computed = compute_node_yield(db, node, yield_date)
|
computed = compute_node_yield(db, node, yield_date)
|
||||||
return ContributionOut(sn=node.sn, **computed.__dict__)
|
return ContributionOut(sn=node.sn, **computed.__dict__)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/yield/settle", response_model=SettleOut)
|
@router.post("/yield/settle", response_model=SettleOut)
|
||||||
def settle(db: Db, yield_date: date | None = None) -> SettleOut:
|
def settle(_: AdminUser, db: Db, yield_date: date | None = None) -> SettleOut:
|
||||||
target = yield_date or (utcnow().date() - timedelta(days=1))
|
target = yield_date or (utcnow().date() - timedelta(days=1))
|
||||||
summary = settle_yield_date(db, target)
|
summary = settle_yield_date(db, target)
|
||||||
return SettleOut(
|
return SettleOut(
|
||||||
@@ -141,52 +213,91 @@ def settle(db: Db, yield_date: date | None = None) -> SettleOut:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/nodes/{sn}/yields", response_model=list[YieldOut])
|
@router.get("/nodes/{sn}/yields", response_model=list[YieldOut])
|
||||||
def yields(sn: str, db: Db) -> list[YieldOut]:
|
def yields(sn: str, user: CurrentUser, db: Db) -> list[YieldOut]:
|
||||||
|
_owned_node(db, user, sn)
|
||||||
return list_node_yields(db, sn)
|
return list_node_yields(db, sn)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/withdrawals", response_model=list[WithdrawalView])
|
@router.get("/withdrawals", response_model=list[WithdrawalView])
|
||||||
def list_withdrawals(db: Db) -> list[WithdrawalView]:
|
def list_withdrawals(_: AdminUser, db: Db) -> list[WithdrawalView]:
|
||||||
rows = db.scalars(
|
return [_withdrawal_view(row) for row in _withdrawal_rows(db)]
|
||||||
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, user: CurrentUser, db: Db) -> BalanceOut:
|
||||||
user = get_user_by_wallet(db, wallet_address)
|
_must_own_wallet(user, wallet_address)
|
||||||
return BalanceOut(wallet_address=user.wallet_address, available_points=available_balance(db, user.id))
|
owner = get_user_by_wallet(db, wallet_address)
|
||||||
|
return BalanceOut(wallet_address=owner.wallet_address, available_points=available_balance(db, owner.id))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/withdrawals", response_model=WithdrawalOut)
|
@router.post("/withdrawals", response_model=WithdrawalOut)
|
||||||
def create_withdrawal(body: WithdrawalCreate, db: Db) -> WithdrawalOut:
|
def create_withdrawal(body: WithdrawalCreate, user: CurrentUser, db: Db) -> WithdrawalOut:
|
||||||
|
_must_own_wallet(user, body.wallet_address)
|
||||||
row, remaining = request_withdrawal(db, body.wallet_address, body.amount, body.asset_symbol)
|
row, remaining = request_withdrawal(db, body.wallet_address, body.amount, body.asset_symbol)
|
||||||
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
|
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
|
@router.post("/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
|
||||||
def confirm(withdrawal_id: int, body: WithdrawalConfirm, db: Db) -> WithdrawalOut:
|
def confirm(withdrawal_id: int, body: WithdrawalConfirm, _: AdminUser, db: Db) -> WithdrawalOut:
|
||||||
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
|
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
|
@router.post("/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
|
||||||
def reject(withdrawal_id: int, body: WithdrawalReject, db: Db) -> WithdrawalOut:
|
def reject(withdrawal_id: int, body: WithdrawalReject, _: AdminUser, 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:
|
def _summary(db: Session) -> SummaryOut:
|
||||||
return NodeView(**NodeOut.model_validate(node).model_dump(), wallet_address=node.user.wallet_address)
|
users = db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
nodes, online = node_counts(db)
|
||||||
|
return SummaryOut(
|
||||||
|
users=users,
|
||||||
|
nodes=nodes,
|
||||||
|
online=online,
|
||||||
|
offline=nodes - online,
|
||||||
|
authorized_amount=authorized_amount(db),
|
||||||
|
settled_points=settled_points(db),
|
||||||
|
pending_withdrawals=pending_withdrawal_amount(db),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sum(values: list[object]) -> Decimal:
|
def _token(user: User) -> TokenOut:
|
||||||
return money(sum((to_decimal(item) for item in values), Decimal("0")))
|
return TokenOut(
|
||||||
|
access_token=create_access_token(user),
|
||||||
|
role=user.role,
|
||||||
|
wallet_address=user.wallet_address,
|
||||||
|
username=user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _must_own_wallet(user: User, wallet_address: str) -> None:
|
||||||
|
if user.role != UserRole.ADMIN and user.wallet_address != wallet_address.strip():
|
||||||
|
raise AppError("只能操作自己的钱包", 403)
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_node(db: Session, user: User, sn: str) -> ComputeNode:
|
||||||
|
node = get_node_by_sn(db, sn)
|
||||||
|
if user.role != UserRole.ADMIN and node.user_id != user.id:
|
||||||
|
raise AppError("无权查看该节点", 403)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _withdrawal_rows(db: Session) -> list[WithdrawalLog]:
|
||||||
|
pending_first = case((WithdrawalLog.status == WithdrawalStatus.PENDING, 0), else_=1)
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(WithdrawalLog)
|
||||||
|
.options(selectinload(WithdrawalLog.user))
|
||||||
|
.order_by(pending_first, WithdrawalLog.id.desc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _withdrawal_view(row: WithdrawalLog) -> WithdrawalView:
|
||||||
|
return WithdrawalView(
|
||||||
|
**WithdrawalOut.model_validate(row).model_dump(),
|
||||||
|
wallet_address=row.user.wallet_address,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _task_out(task: ComputeTask) -> TaskOut:
|
def _task_out(task: ComputeTask) -> TaskOut:
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class Settings(BaseSettings):
|
|||||||
yield_minute: int = 5
|
yield_minute: int = 5
|
||||||
license_months: int = 12
|
license_months: int = 12
|
||||||
disable_scheduler: bool = False
|
disable_scheduler: bool = False
|
||||||
|
jwt_secret: str = ""
|
||||||
|
jwt_expire_minutes: int = 60 * 24
|
||||||
|
admin_username: str = "admin"
|
||||||
|
admin_password: str = ""
|
||||||
|
admin_wallet: str = "admin-root"
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
"""从 Authorization 头解析当前用户,并区分管理员。"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.errors import AppError
|
||||||
|
from app.models import User, UserRole
|
||||||
|
from app.security import read_user_id
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> User:
|
||||||
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
|
raise AppError("未登录", 401)
|
||||||
|
user = db.get(User, read_user_id(credentials.credentials))
|
||||||
|
if user is None:
|
||||||
|
raise AppError("登录已失效", 401)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||||
|
if user.role != UserRole.ADMIN:
|
||||||
|
raise AppError("需要管理员权限", 403)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
AdminUser = Annotated[User, Depends(require_admin)]
|
||||||
+36
-12
@@ -12,9 +12,12 @@ 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
|
||||||
from app.database import Base, engine
|
from app.database import Base, engine, session_scope
|
||||||
from app.errors import AppError
|
from app.errors import AppError
|
||||||
from app.scheduler import start_scheduler, stop_scheduler
|
from app.scheduler import start_scheduler, stop_scheduler
|
||||||
|
from app.schema_upgrade import upgrade_schema
|
||||||
|
from app.security import ensure_jwt_secret
|
||||||
|
from app.services.auth import ensure_admin
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
|
|
||||||
@@ -22,6 +25,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
upgrade_schema()
|
||||||
|
with session_scope() as session:
|
||||||
|
ensure_jwt_secret(session)
|
||||||
|
ensure_admin(session)
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -33,19 +40,36 @@ WEB_DIR = Path(__file__).resolve().parent.parent / "web"
|
|||||||
|
|
||||||
app = FastAPI(title="HyperNode-AI", version="1.0.0", lifespan=lifespan)
|
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")
|
_assets = WEB_DIR / "assets"
|
||||||
|
if _assets.is_dir():
|
||||||
|
app.mount("/assets", StaticFiles(directory=_assets), name="assets")
|
||||||
@app.get("/")
|
|
||||||
def console() -> FileResponse:
|
|
||||||
return FileResponse(WEB_DIR / "index.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(AppError)
|
|
||||||
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
|
|
||||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def console() -> FileResponse:
|
||||||
|
return _spa("")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
def console_routes(full_path: str) -> FileResponse:
|
||||||
|
return _spa(full_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _spa(full_path: str) -> FileResponse:
|
||||||
|
root = WEB_DIR.resolve()
|
||||||
|
index = root / "index.html"
|
||||||
|
if full_path:
|
||||||
|
candidate = (root / full_path).resolve()
|
||||||
|
if candidate.is_file() and (candidate == root or root in candidate.parents):
|
||||||
|
return FileResponse(candidate)
|
||||||
|
return FileResponse(index)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ class PushStatus(str, enum.Enum):
|
|||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
USER = "user"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""用户。一个钱包地址对应一名用户,可绑定多台算力节点。"""
|
"""用户。一个钱包地址对应一名用户,可绑定多台算力节点。"""
|
||||||
|
|
||||||
@@ -105,6 +110,9 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
wallet_address: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
wallet_address: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||||
|
username: Mapped[str | None] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
role: Mapped[UserRole] = mapped_column(_enum(UserRole), default=UserRole.USER, index=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
|
||||||
|
|
||||||
nodes: Mapped[list[ComputeNode]] = relationship(back_populates="user")
|
nodes: Mapped[list[ComputeNode]] = relationship(back_populates="user")
|
||||||
@@ -241,6 +249,15 @@ class WithdrawalLog(Base):
|
|||||||
user: Mapped[User] = relationship(back_populates="withdrawals")
|
user: Mapped[User] = relationship(back_populates="withdrawals")
|
||||||
|
|
||||||
|
|
||||||
|
class AppSetting(Base):
|
||||||
|
"""进程重启后仍要保留的运行时密钥,例如未写入环境变量的 JWT 密钥。"""
|
||||||
|
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
class SchedulerLock(Base):
|
class SchedulerLock(Base):
|
||||||
"""定时任务锁。锁到期后其他实例才能接手,进程崩溃不会永久占住任务。"""
|
"""定时任务锁。锁到期后其他实例才能接手,进程崩溃不会永久占住任务。"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""给已经上线的库补上认证字段。create_all 不会修改旧表。"""
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
from app.database import engine
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_schema() -> None:
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if "users" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||||
|
statements: list[str] = []
|
||||||
|
if "username" not in columns:
|
||||||
|
statements.append("ALTER TABLE users ADD COLUMN username VARCHAR(32)")
|
||||||
|
if "password_hash" not in columns:
|
||||||
|
statements.append("ALTER TABLE users ADD COLUMN password_hash VARCHAR(255)")
|
||||||
|
if "role" not in columns:
|
||||||
|
statements.append("ALTER TABLE users ADD COLUMN role VARCHAR(16) DEFAULT 'user' NOT NULL")
|
||||||
|
if not statements and _has_username_index(inspector):
|
||||||
|
return
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for statement in statements:
|
||||||
|
conn.execute(text(statement))
|
||||||
|
if not _has_username_index(inspector):
|
||||||
|
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_users_username ON users (username)"))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_username_index(inspector) -> bool:
|
||||||
|
if "users" not in inspector.get_table_names():
|
||||||
|
return False
|
||||||
|
for index in inspector.get_indexes("users"):
|
||||||
|
if index.get("column_names") == ["username"] and index.get("unique"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
+58
-1
@@ -8,10 +8,11 @@ from typing import Annotated
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
|
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
|
||||||
|
|
||||||
from app.models import NodeStatus, TaskStatus, WithdrawalStatus, YieldStatus
|
from app.models import NodeStatus, TaskStatus, UserRole, WithdrawalStatus, YieldStatus
|
||||||
|
|
||||||
SN = Annotated[str, Field(min_length=4, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")]
|
SN = Annotated[str, Field(min_length=4, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")]
|
||||||
WALLET = Annotated[str, Field(min_length=4, max_length=128, pattern=r"^[A-Za-z0-9:_-]+$")]
|
WALLET = Annotated[str, Field(min_length=4, max_length=128, pattern=r"^[A-Za-z0-9:_-]+$")]
|
||||||
|
USERNAME = Annotated[str, Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_]+$")]
|
||||||
MoneyText = Annotated[Decimal, PlainSerializer(lambda value: format(value, "f"), return_type=str)]
|
MoneyText = Annotated[Decimal, PlainSerializer(lambda value: format(value, "f"), return_type=str)]
|
||||||
|
|
||||||
|
|
||||||
@@ -26,9 +27,41 @@ class UserCreate(APIModel):
|
|||||||
class UserOut(APIModel):
|
class UserOut(APIModel):
|
||||||
id: int
|
id: int
|
||||||
wallet_address: str
|
wallet_address: str
|
||||||
|
username: str | None = None
|
||||||
|
role: UserRole = UserRole.USER
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterIn(APIModel):
|
||||||
|
wallet_address: WALLET
|
||||||
|
username: USERNAME | None = None
|
||||||
|
password: str | None = Field(default=None, max_length=72)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginIn(APIModel):
|
||||||
|
wallet_address: str | None = Field(default=None, max_length=128)
|
||||||
|
username: str | None = Field(default=None, max_length=32)
|
||||||
|
password: str | None = Field(default=None, max_length=72)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenOut(APIModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
role: UserRole
|
||||||
|
wallet_address: str
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserOut(APIModel):
|
||||||
|
id: int
|
||||||
|
wallet_address: str
|
||||||
|
username: str | None
|
||||||
|
role: UserRole
|
||||||
|
created_at: datetime
|
||||||
|
node_count: int
|
||||||
|
available_points: MoneyText
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(APIModel):
|
class NodeCreate(APIModel):
|
||||||
wallet_address: WALLET
|
wallet_address: WALLET
|
||||||
sn: SN
|
sn: SN
|
||||||
@@ -51,6 +84,8 @@ class NodeOut(APIModel):
|
|||||||
|
|
||||||
class NodeView(NodeOut):
|
class NodeView(NodeOut):
|
||||||
wallet_address: str
|
wallet_address: str
|
||||||
|
online_rate: MoneyText = Decimal("0")
|
||||||
|
today_points: MoneyText = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatIn(APIModel):
|
class HeartbeatIn(APIModel):
|
||||||
@@ -134,6 +169,17 @@ class WithdrawalCreate(APIModel):
|
|||||||
asset_symbol: str = Field(default="POINT", min_length=1, max_length=32)
|
asset_symbol: str = Field(default="POINT", min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class MeWithdrawalCreate(APIModel):
|
||||||
|
amount: Decimal = Field(gt=0, max_digits=24, decimal_places=8)
|
||||||
|
asset_symbol: str = Field(default="POINT", min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class MeNodeCreate(APIModel):
|
||||||
|
sn: SN
|
||||||
|
purchase_amount: Decimal = Field(gt=0, max_digits=24, decimal_places=8)
|
||||||
|
callback_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WithdrawalConfirm(APIModel):
|
class WithdrawalConfirm(APIModel):
|
||||||
tx_hash: str = Field(min_length=6, max_length=128, pattern=r"^[A-Za-z0-9]+$")
|
tx_hash: str = Field(min_length=6, max_length=128, pattern=r"^[A-Za-z0-9]+$")
|
||||||
|
|
||||||
@@ -172,3 +218,14 @@ class SummaryOut(APIModel):
|
|||||||
authorized_amount: MoneyText
|
authorized_amount: MoneyText
|
||||||
settled_points: MoneyText
|
settled_points: MoneyText
|
||||||
pending_withdrawals: MoneyText
|
pending_withdrawals: MoneyText
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardOut(APIModel):
|
||||||
|
wallet_address: str
|
||||||
|
username: str | None
|
||||||
|
role: UserRole
|
||||||
|
available_points: MoneyText
|
||||||
|
settled_points: MoneyText
|
||||||
|
today_points: MoneyText
|
||||||
|
nodes: list[NodeView]
|
||||||
|
withdrawals: list[WithdrawalOut]
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""密码哈希与 JWT。密钥优先用环境变量,否则写入 app_settings,重启后仍然有效。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.clock import utcnow
|
||||||
|
from app.config import settings
|
||||||
|
from app.errors import AppError
|
||||||
|
from app.models import AppSetting, User
|
||||||
|
|
||||||
|
_secret: str | None = None
|
||||||
|
_ITERATIONS = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _ITERATIONS)
|
||||||
|
return f"pbkdf2_sha256${_ITERATIONS}${_b64(salt)}${_b64(digest)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str | None) -> bool:
|
||||||
|
if not stored:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
algo, rounds, salt_b64, digest_b64 = stored.split("$", 3)
|
||||||
|
if algo != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
got = hashlib.pbkdf2_hmac("sha256", password.encode(), _unb64(salt_b64), int(rounds))
|
||||||
|
return hmac.compare_digest(got, _unb64(digest_b64))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_jwt_secret(session: Session) -> None:
|
||||||
|
global _secret
|
||||||
|
if settings.jwt_secret:
|
||||||
|
_secret = settings.jwt_secret
|
||||||
|
return
|
||||||
|
row = session.get(AppSetting, "jwt_secret")
|
||||||
|
if row is None:
|
||||||
|
row = AppSetting(key="jwt_secret", value=secrets.token_hex(32))
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
_secret = row.value
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user: User) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": str(user.id),
|
||||||
|
"role": user.role.value,
|
||||||
|
"exp": utcnow() + timedelta(minutes=settings.jwt_expire_minutes),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, _require_secret(), algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def read_user_id(token: str) -> int:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, _require_secret(), algorithms=["HS256"])
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise AppError("登录已失效", 401) from exc
|
||||||
|
subject = payload.get("sub")
|
||||||
|
if not subject:
|
||||||
|
raise AppError("登录已失效", 401)
|
||||||
|
return int(subject)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_secret() -> str:
|
||||||
|
if not _secret:
|
||||||
|
raise AppError("认证尚未初始化", 500)
|
||||||
|
return _secret
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(raw: bytes) -> str:
|
||||||
|
return urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(text: str) -> bytes:
|
||||||
|
padding = "=" * (-len(text) % 4)
|
||||||
|
return urlsafe_b64decode(text + padding)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""注册、登录,以及首次启动时创建管理员。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.clock import utcnow
|
||||||
|
from app.config import settings
|
||||||
|
from app.errors import AppError
|
||||||
|
from app.models import User, UserRole
|
||||||
|
from app.security import hash_password, verify_password
|
||||||
|
from app.services.registry import register_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register_account(
|
||||||
|
session: Session,
|
||||||
|
wallet_address: str,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
) -> User:
|
||||||
|
name = _clean_username(username)
|
||||||
|
secret = (password or "").strip()
|
||||||
|
if name and not secret:
|
||||||
|
raise AppError("设置用户名时必须填写密码")
|
||||||
|
if secret and not name:
|
||||||
|
raise AppError("设置密码时必须填写用户名")
|
||||||
|
if secret and len(secret) < 6:
|
||||||
|
raise AppError("密码至少 6 位")
|
||||||
|
return register_user(
|
||||||
|
session,
|
||||||
|
wallet_address,
|
||||||
|
username=name,
|
||||||
|
password_hash=hash_password(secret) if secret else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(
|
||||||
|
session: Session,
|
||||||
|
wallet_address: str | None,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
) -> User:
|
||||||
|
name = _clean_username(username)
|
||||||
|
wallet = (wallet_address or "").strip()
|
||||||
|
secret = (password or "").strip()
|
||||||
|
if name and secret:
|
||||||
|
user = session.scalar(select(User).where(User.username == name))
|
||||||
|
if user is None or not verify_password(secret, user.password_hash):
|
||||||
|
raise AppError("账号或密码错误", 401)
|
||||||
|
return user
|
||||||
|
if wallet and secret:
|
||||||
|
user = session.scalar(select(User).where(User.wallet_address == wallet))
|
||||||
|
if user is None or not verify_password(secret, user.password_hash):
|
||||||
|
raise AppError("账号或密码错误", 401)
|
||||||
|
return user
|
||||||
|
if wallet:
|
||||||
|
user = session.scalar(select(User).where(User.wallet_address == wallet))
|
||||||
|
if user is None:
|
||||||
|
raise AppError("钱包未注册", 401)
|
||||||
|
if user.role == UserRole.ADMIN:
|
||||||
|
raise AppError("管理员请使用账号密码登录", 403)
|
||||||
|
return user
|
||||||
|
raise AppError("请填写钱包地址,或账号和密码")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_admin(session: Session) -> None:
|
||||||
|
username = _clean_username(settings.admin_username) or "admin"
|
||||||
|
existing = session.scalar(select(User).where(User.username == username))
|
||||||
|
if existing is not None:
|
||||||
|
if existing.role != UserRole.ADMIN:
|
||||||
|
existing.role = UserRole.ADMIN
|
||||||
|
session.flush()
|
||||||
|
return
|
||||||
|
generated = not settings.admin_password
|
||||||
|
password = settings.admin_password or secrets.token_urlsafe(12)
|
||||||
|
wallet = settings.admin_wallet.strip() or "admin-root"
|
||||||
|
if session.scalar(select(User).where(User.wallet_address == wallet)) is not None:
|
||||||
|
wallet = f"{wallet}-{secrets.token_hex(3)}"
|
||||||
|
session.add(
|
||||||
|
User(
|
||||||
|
wallet_address=wallet,
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
created_at=utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
if generated:
|
||||||
|
logger.warning("已创建管理员 %s,初始密码:%s", username, password)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_username(username: str | None) -> str | None:
|
||||||
|
if username is None:
|
||||||
|
return None
|
||||||
|
cleaned = username.strip().lower()
|
||||||
|
return cleaned or None
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""控制台要看的实时快照。今日积分按产出公式预览,不写入流水。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.clock import utcnow
|
||||||
|
from app.models import ComputeNode, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus
|
||||||
|
from app.money import money, to_decimal
|
||||||
|
from app.services.withdrawals import available_balance
|
||||||
|
from app.services.yield_engine import compute_node_yield
|
||||||
|
|
||||||
|
|
||||||
|
def settled_points(session: Session, user_id: int | None = None) -> Decimal:
|
||||||
|
query = select(NodeYield.points_delta).where(NodeYield.status == YieldStatus.SETTLED)
|
||||||
|
if user_id is not None:
|
||||||
|
query = query.where(NodeYield.user_id == user_id)
|
||||||
|
rows = session.scalars(query).all()
|
||||||
|
return money(sum((to_decimal(item) for item in rows), Decimal("0")))
|
||||||
|
|
||||||
|
|
||||||
|
def pending_withdrawal_amount(session: Session) -> Decimal:
|
||||||
|
rows = session.scalars(
|
||||||
|
select(WithdrawalLog.amount).where(WithdrawalLog.status == WithdrawalStatus.PENDING)
|
||||||
|
).all()
|
||||||
|
return money(sum((to_decimal(item) for item in rows), Decimal("0")))
|
||||||
|
|
||||||
|
|
||||||
|
def authorized_amount(session: Session) -> Decimal:
|
||||||
|
rows = session.scalars(select(ComputeNode.purchase_amount)).all()
|
||||||
|
return money(sum((to_decimal(item) for item in rows), Decimal("0")))
|
||||||
|
|
||||||
|
|
||||||
|
def node_counts(session: Session) -> tuple[int, int]:
|
||||||
|
nodes = session.scalar(select(func.count()).select_from(ComputeNode)) or 0
|
||||||
|
online = session.scalar(
|
||||||
|
select(func.count()).select_from(ComputeNode).where(ComputeNode.status == NodeStatus.ONLINE)
|
||||||
|
) or 0
|
||||||
|
return nodes, online
|
||||||
|
|
||||||
|
|
||||||
|
def live_nodes(session: Session, user_id: int | None = None) -> list[dict]:
|
||||||
|
query = select(ComputeNode).options(selectinload(ComputeNode.user)).order_by(ComputeNode.id.desc())
|
||||||
|
if user_id is not None:
|
||||||
|
query = query.where(ComputeNode.user_id == user_id)
|
||||||
|
today = utcnow().date()
|
||||||
|
snapshots = []
|
||||||
|
for node in session.scalars(query).all():
|
||||||
|
computed = compute_node_yield(session, node, today)
|
||||||
|
snapshots.append(
|
||||||
|
{
|
||||||
|
"id": node.id,
|
||||||
|
"sn": node.sn,
|
||||||
|
"user_id": node.user_id,
|
||||||
|
"wallet_address": node.user.wallet_address,
|
||||||
|
"purchase_amount": node.purchase_amount,
|
||||||
|
"license_months": node.license_months,
|
||||||
|
"licensed_at": node.licensed_at,
|
||||||
|
"license_expires_at": node.license_expires_at,
|
||||||
|
"status": node.status,
|
||||||
|
"last_heartbeat_at": node.last_heartbeat_at,
|
||||||
|
"callback_url": node.callback_url,
|
||||||
|
"online_rate": computed.online_rate,
|
||||||
|
"today_points": computed.points_delta if computed.eligible else Decimal("0"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def user_rows(session: Session) -> list[dict]:
|
||||||
|
counts = dict(
|
||||||
|
session.execute(select(ComputeNode.user_id, func.count()).group_by(ComputeNode.user_id)).all()
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for user in session.scalars(select(User).order_by(User.id.desc())).all():
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": user.id,
|
||||||
|
"wallet_address": user.wallet_address,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"created_at": user.created_at,
|
||||||
|
"node_count": int(counts.get(user.id, 0)),
|
||||||
|
"available_points": available_balance(session, user.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
@@ -12,22 +12,36 @@ from sqlalchemy.orm import Session
|
|||||||
from app.clock import add_months, utcnow
|
from app.clock import add_months, utcnow
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.errors import AppError
|
from app.errors import AppError
|
||||||
from app.models import ComputeNode, NodeStatus, User
|
from app.models import ComputeNode, NodeStatus, User, UserRole
|
||||||
from app.money import money
|
from app.money import money
|
||||||
from app.services.common import get_user_by_wallet
|
from app.services.common import get_user_by_wallet
|
||||||
|
|
||||||
|
|
||||||
def register_user(session: Session, wallet_address: str) -> User:
|
def register_user(
|
||||||
|
session: Session,
|
||||||
|
wallet_address: str,
|
||||||
|
username: str | None = None,
|
||||||
|
password_hash: str | None = None,
|
||||||
|
role: UserRole = UserRole.USER,
|
||||||
|
) -> User:
|
||||||
wallet = wallet_address.strip()
|
wallet = wallet_address.strip()
|
||||||
if session.scalar(select(User).where(User.wallet_address == wallet)) is not None:
|
if session.scalar(select(User).where(User.wallet_address == wallet)) is not None:
|
||||||
raise AppError("钱包地址已注册", 409)
|
raise AppError("钱包地址已注册", 409)
|
||||||
user = User(wallet_address=wallet, created_at=utcnow())
|
if username and session.scalar(select(User).where(User.username == username)) is not None:
|
||||||
|
raise AppError("用户名已占用", 409)
|
||||||
|
user = User(
|
||||||
|
wallet_address=wallet,
|
||||||
|
username=username,
|
||||||
|
password_hash=password_hash,
|
||||||
|
role=role,
|
||||||
|
created_at=utcnow(),
|
||||||
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
try:
|
try:
|
||||||
session.flush()
|
session.flush()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise AppError("钱包地址已注册", 409) from exc
|
raise AppError("钱包地址或用户名已注册", 409) from exc
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ SERVICE_NAME="hypernode"
|
|||||||
BIND_HOST="0.0.0.0"
|
BIND_HOST="0.0.0.0"
|
||||||
BIND_PORT="33333"
|
BIND_PORT="33333"
|
||||||
ENV_FILE="${INSTALL_ROOT}/.env"
|
ENV_FILE="${INSTALL_ROOT}/.env"
|
||||||
|
GENERATED_ADMIN_PASSWORD=""
|
||||||
|
|
||||||
_script_src="${BASH_SOURCE[0]:-}"
|
_script_src="${BASH_SOURCE[0]:-}"
|
||||||
if [[ -n "${_script_src}" && -f "${_script_src}" ]]; then
|
if [[ -n "${_script_src}" && -f "${_script_src}" ]]; then
|
||||||
@@ -132,6 +133,10 @@ write_env() {
|
|||||||
log "保留已有配置 ${ENV_FILE}"
|
log "保留已有配置 ${ENV_FILE}"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
local jwt_secret admin_password
|
||||||
|
jwt_secret="$(openssl rand -hex 32)"
|
||||||
|
admin_password="$(openssl rand -hex 12)"
|
||||||
|
GENERATED_ADMIN_PASSWORD="${admin_password}"
|
||||||
log "写入 ${ENV_FILE}"
|
log "写入 ${ENV_FILE}"
|
||||||
cat >"${ENV_FILE}" <<EOF
|
cat >"${ENV_FILE}" <<EOF
|
||||||
DATABASE_URL=sqlite:////${DATA_DIR}/hypernode.db
|
DATABASE_URL=sqlite:////${DATA_DIR}/hypernode.db
|
||||||
@@ -139,6 +144,10 @@ YIELD_TIMEZONE=Asia/Shanghai
|
|||||||
YIELD_HOUR=0
|
YIELD_HOUR=0
|
||||||
YIELD_MINUTE=5
|
YIELD_MINUTE=5
|
||||||
DISABLE_SCHEDULER=false
|
DISABLE_SCHEDULER=false
|
||||||
|
JWT_SECRET=${jwt_secret}
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=${admin_password}
|
||||||
|
ADMIN_WALLET=admin-root
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +229,13 @@ cmd_deploy() {
|
|||||||
log "部署完成"
|
log "部署完成"
|
||||||
echo "监听地址:${BIND_HOST}:${BIND_PORT}"
|
echo "监听地址:${BIND_HOST}:${BIND_PORT}"
|
||||||
echo "健康检查:curl http://127.0.0.1:${BIND_PORT}/health"
|
echo "健康检查:curl http://127.0.0.1:${BIND_PORT}/health"
|
||||||
|
echo "控制台:http://<服务器 IP>:${BIND_PORT}/"
|
||||||
echo "接口文档:http://<服务器 IP>:${BIND_PORT}/docs"
|
echo "接口文档:http://<服务器 IP>:${BIND_PORT}/docs"
|
||||||
|
if [[ -n "${GENERATED_ADMIN_PASSWORD}" ]]; then
|
||||||
|
echo "管理员账号:admin"
|
||||||
|
echo "管理员密码:${GENERATED_ADMIN_PASSWORD}"
|
||||||
|
echo "密码写在 ${ENV_FILE},请立刻记下。"
|
||||||
|
fi
|
||||||
echo "程序目录:${INSTALL_ROOT}"
|
echo "程序目录:${INSTALL_ROOT}"
|
||||||
echo "数据库:${DATA_DIR}/hypernode.db"
|
echo "数据库:${DATA_DIR}/hypernode.db"
|
||||||
echo "日志:journalctl -u ${SERVICE_NAME}.service -f"
|
echo "日志:journalctl -u ${SERVICE_NAME}.service -f"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!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" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1285
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "hypernode-console",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const KEYS = ["token", "role", "wallet", "username"];
|
||||||
|
|
||||||
|
export function session() {
|
||||||
|
return {
|
||||||
|
token: localStorage.getItem("token") || "",
|
||||||
|
role: localStorage.getItem("role") || "",
|
||||||
|
wallet: localStorage.getItem("wallet") || "",
|
||||||
|
username: localStorage.getItem("username") || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(payload) {
|
||||||
|
localStorage.setItem("token", payload.access_token);
|
||||||
|
localStorage.setItem("role", payload.role);
|
||||||
|
localStorage.setItem("wallet", payload.wallet_address);
|
||||||
|
localStorage.setItem("username", payload.username || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
KEYS.forEach((key) => localStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api(path, options = {}) {
|
||||||
|
const headers = { ...(options.headers || {}) };
|
||||||
|
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
const response = await fetch(`/api/v1${path}`, { ...options, headers });
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearSession();
|
||||||
|
if (location.pathname !== "/login") location.assign("/login");
|
||||||
|
throw new Error(messageOf(data) || "登录已失效");
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(messageOf(data) || "请求失败");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageOf(data) {
|
||||||
|
if (!data) return "";
|
||||||
|
if (typeof data.detail === "string") return data.detail;
|
||||||
|
if (Array.isArray(data.detail)) return data.detail.map((item) => item.msg).filter(Boolean).join(";");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { clearSession, session } from "../api";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
eyebrow: { type: String, default: "控制面" },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const current = session();
|
||||||
|
const clock = ref("--:--:--");
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
clock.value = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
}).format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearSession();
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
tick();
|
||||||
|
timer = window.setInterval(tick, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => window.clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="rail">
|
||||||
|
<router-link class="brand" to="/">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>HyperNode</strong>
|
||||||
|
<em>Compute Atelier</em>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
<nav class="nav">
|
||||||
|
<router-link to="/dashboard">我的节点</router-link>
|
||||||
|
<router-link v-if="current.role === 'admin'" to="/admin">管理后台</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="rail-foot">
|
||||||
|
<span class="who">{{ current.username || current.wallet }}</span>
|
||||||
|
<button type="button" class="texty" @click="logout">退出</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="stage">
|
||||||
|
<header class="top">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{{ eyebrow }}</p>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="clock">
|
||||||
|
<span>北京时间</span>
|
||||||
|
<strong>{{ clock }}</strong>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="view">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export function money(value) {
|
||||||
|
const [whole, fraction = ""] = String(value ?? "0").split(".");
|
||||||
|
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
const trimmed = fraction.replace(/0+$/, "");
|
||||||
|
return trimmed ? `${grouped}.${trimmed}` : grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pct(value) {
|
||||||
|
const number = Number(value) * 100;
|
||||||
|
if (!Number.isFinite(number)) return "0%";
|
||||||
|
return `${number.toFixed(number >= 10 ? 0 : 1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function when(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statusText = {
|
||||||
|
online: "在线",
|
||||||
|
offline: "离线",
|
||||||
|
pending: "待审核",
|
||||||
|
completed: "已打款",
|
||||||
|
rejected: "已拒绝",
|
||||||
|
failed: "失败",
|
||||||
|
settled: "已入账",
|
||||||
|
user: "用户",
|
||||||
|
admin: "管理员",
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { router } from "./router";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createApp(App).use(router).mount("#app");
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import Login from "../views/Login.vue";
|
||||||
|
import Dashboard from "../views/Dashboard.vue";
|
||||||
|
import AdminOverview from "../views/AdminOverview.vue";
|
||||||
|
import { session } from "../api";
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: "/", redirect: () => (session().role === "admin" ? "/admin" : "/dashboard") },
|
||||||
|
{ path: "/login", component: Login },
|
||||||
|
{ path: "/dashboard", component: Dashboard, meta: { auth: true } },
|
||||||
|
{ path: "/admin", component: AdminOverview, meta: { auth: true, admin: true } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const current = session();
|
||||||
|
if (to.meta.auth && !current.token) return "/login";
|
||||||
|
if (to.meta.admin && current.role !== "admin") return "/dashboard";
|
||||||
|
if (to.path === "/login" && current.token) return current.role === "admin" ? "/admin" : "/dashboard";
|
||||||
|
return true;
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #08090d;
|
||||||
|
--ink: #f4efe6;
|
||||||
|
--muted: #a3988a;
|
||||||
|
--gold: #e0c07a;
|
||||||
|
--line: rgba(224, 192, 122, 0.18);
|
||||||
|
--panel: rgba(16, 18, 24, 0.82);
|
||||||
|
--online: #8ed0b5;
|
||||||
|
--offline: #d08a78;
|
||||||
|
--serif: "Fraunces", Palatino, serif;
|
||||||
|
--sans: "Manrope", "Segoe UI", sans-serif;
|
||||||
|
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app { min-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 { font: inherit; color: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
h1, h2, p { margin: 0; }
|
||||||
|
a { color: inherit; }
|
||||||
|
|
||||||
|
.gate {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: 48px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 64px 8vw;
|
||||||
|
}
|
||||||
|
.gate-copy h1 {
|
||||||
|
margin: 12px 0 18px;
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: clamp(52px, 6vw, 88px);
|
||||||
|
font-weight: 560;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
.gate-copy p:last-child { max-width: 420px; color: var(--muted); font-size: 18px; line-height: 1.6; }
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--gold);
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.gate-card, .panel, .metric, .plate, .table-wrap {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.gate-card { display: grid; gap: 14px; padding: 28px; }
|
||||||
|
.switch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.switch button, .texty, .ghost {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.switch button.on { color: var(--ink); background: rgba(224, 192, 122, 0.1); border-color: var(--line); }
|
||||||
|
.gold {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: linear-gradient(180deg, #f0d7a2, #c4964e);
|
||||||
|
color: #24180b;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.gold.wide { width: 100%; margin-top: 4px; }
|
||||||
|
.gold:disabled { opacity: 0.6; }
|
||||||
|
label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(8, 9, 13, 0.55);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--gold); }
|
||||||
|
.hint, .sub { color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||||
|
.form-error { color: #e7b2a6; font-size: 14px; }
|
||||||
|
.banner { margin-bottom: 14px; }
|
||||||
|
.banner.ok { color: var(--online); }
|
||||||
|
|
||||||
|
.shell { min-height: 100vh; 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);
|
||||||
|
}
|
||||||
|
.brand { display: flex; gap: 12px; align-items: center; text-decoration: none; }
|
||||||
|
.mark {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--gold);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.brand strong { display: block; font-family: var(--serif); font-size: 22px; font-weight: 560; }
|
||||||
|
.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 a { text-decoration: none; color: var(--muted); border-radius: 12px; padding: 12px 14px; }
|
||||||
|
.nav a.router-link-active { color: var(--ink); background: rgba(224, 192, 122, 0.08); box-shadow: inset 2px 0 0 var(--gold); }
|
||||||
|
.rail-foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||||
|
.who { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.stage { padding: 28px 32px 64px; }
|
||||||
|
.top { display: flex; justify-content: space-between; align-items: end; gap: 16px; }
|
||||||
|
.top h1 { margin-top: 8px; font-family: var(--serif); font-size: 56px; font-weight: 560; letter-spacing: -0.04em; }
|
||||||
|
.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; }
|
||||||
|
.view { margin-top: 28px; }
|
||||||
|
|
||||||
|
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
||||||
|
.metric { padding: 22px; }
|
||||||
|
.metric span { color: var(--muted); font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; }
|
||||||
|
.metric strong { display: block; margin-top: 12px; font-family: var(--serif); font-size: 40px; font-weight: 560; letter-spacing: -0.04em; }
|
||||||
|
.metric em { display: block; margin-top: 8px; color: var(--muted); font-style: normal; font-size: 13px; }
|
||||||
|
.section-title { margin: 28px 0 14px; font-family: var(--serif); font-weight: 560; font-size: 28px; }
|
||||||
|
.board { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 16px; }
|
||||||
|
.plate { padding: 18px; }
|
||||||
|
.plate header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||||
|
.plate header strong { font-family: var(--mono); font-size: 14px; }
|
||||||
|
.wallet { margin-top: 8px; color: var(--muted); font-family: var(--mono); font-size: 12px; }
|
||||||
|
.rate { position: relative; overflow: hidden; margin: 14px 0; padding: 8px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.03); color: var(--muted); font-size: 12px; }
|
||||||
|
.rate b { position: absolute; inset: 0 auto 0 0; background: rgba(224, 192, 122, 0.18); }
|
||||||
|
.plate dl { display: grid; gap: 8px; margin: 0; }
|
||||||
|
.plate dl div { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
|
||||||
|
.plate dt { color: var(--muted); }
|
||||||
|
.plate dd { margin: 0; }
|
||||||
|
.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, .pill.completed { color: var(--online); }
|
||||||
|
.pill.offline, .pill.rejected, .pill.failed { color: var(--offline); }
|
||||||
|
.pill.pending { color: var(--gold); }
|
||||||
|
.forms { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; align-items: start; }
|
||||||
|
.panel { display: grid; gap: 12px; padding: 22px; }
|
||||||
|
.panel h2 { font-family: var(--serif); font-size: 28px; font-weight: 560; }
|
||||||
|
.table-wrap { overflow: auto; margin-top: 8px; }
|
||||||
|
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; letter-spacing: 0.14em; text-transform: uppercase; }
|
||||||
|
td input { width: 180px; padding: 8px 10px; }
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
.mono { font-family: var(--mono); font-size: 12px; }
|
||||||
|
.empty { padding: 28px 16px; color: var(--muted); }
|
||||||
|
.mini { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 8px; }
|
||||||
|
.mini li { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.metrics, .forms, .gate { grid-template-columns: 1fr; }
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.rail { position: relative; height: auto; }
|
||||||
|
.top h1 { font-size: 40px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import Shell from "../components/Shell.vue";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { money, pct, statusText, when } from "../format";
|
||||||
|
|
||||||
|
const summary = ref(null);
|
||||||
|
const users = ref([]);
|
||||||
|
const nodes = ref([]);
|
||||||
|
const withdrawals = ref([]);
|
||||||
|
const hashes = ref({});
|
||||||
|
const error = ref("");
|
||||||
|
const notice = ref("");
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const [nextSummary, nextUsers, nextNodes, nextCash] = await Promise.all([
|
||||||
|
api("/admin/summary"),
|
||||||
|
api("/admin/users"),
|
||||||
|
api("/admin/nodes"),
|
||||||
|
api("/admin/withdrawals"),
|
||||||
|
]);
|
||||||
|
summary.value = nextSummary;
|
||||||
|
users.value = nextUsers;
|
||||||
|
nodes.value = nextNodes;
|
||||||
|
withdrawals.value = nextCash;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm(row) {
|
||||||
|
const txHash = (hashes.value[row.id] || "").trim();
|
||||||
|
if (!txHash) {
|
||||||
|
error.value = "请先填写链上哈希";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = "";
|
||||||
|
notice.value = "";
|
||||||
|
try {
|
||||||
|
await api(`/admin/withdrawals/${row.id}/confirm`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ tx_hash: txHash }),
|
||||||
|
});
|
||||||
|
notice.value = `提现 #${row.id} 已打款`;
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject(row) {
|
||||||
|
error.value = "";
|
||||||
|
notice.value = "";
|
||||||
|
try {
|
||||||
|
await api(`/admin/withdrawals/${row.id}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ note: "管理后台拒绝" }),
|
||||||
|
});
|
||||||
|
notice.value = `提现 #${row.id} 已拒绝`;
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message;
|
||||||
|
}
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
load().catch((err) => {
|
||||||
|
error.value = err.message;
|
||||||
|
});
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => window.clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Shell eyebrow="管理后台" title="全网概览">
|
||||||
|
<p v-if="error" class="form-error banner">{{ error }}</p>
|
||||||
|
<p v-if="notice" class="banner ok">{{ notice }}</p>
|
||||||
|
<section v-if="summary" class="metrics">
|
||||||
|
<article class="metric">
|
||||||
|
<span>用户</span>
|
||||||
|
<strong>{{ summary.users }}</strong>
|
||||||
|
<em>已注册钱包</em>
|
||||||
|
</article>
|
||||||
|
<article class="metric">
|
||||||
|
<span>节点</span>
|
||||||
|
<strong>{{ summary.nodes }}</strong>
|
||||||
|
<em>在线 {{ summary.online }} · 离线 {{ summary.offline }}</em>
|
||||||
|
</article>
|
||||||
|
<article class="metric">
|
||||||
|
<span>总产出</span>
|
||||||
|
<strong>{{ money(summary.settled_points) }}</strong>
|
||||||
|
<em>授权 {{ money(summary.authorized_amount) }}</em>
|
||||||
|
</article>
|
||||||
|
<article class="metric">
|
||||||
|
<span>待审核</span>
|
||||||
|
<strong>{{ money(summary.pending_withdrawals) }}</strong>
|
||||||
|
<em>占用中的提现</em>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 class="section-title">节点监控</h2>
|
||||||
|
<section class="board">
|
||||||
|
<article v-for="node in nodes" :key="node.sn" class="plate">
|
||||||
|
<header>
|
||||||
|
<strong>{{ node.sn }}</strong>
|
||||||
|
<span class="pill" :class="node.status"><i />{{ statusText[node.status] }}</span>
|
||||||
|
</header>
|
||||||
|
<p class="wallet">{{ node.wallet_address }}</p>
|
||||||
|
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线率</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
|
||||||
|
<div><dt>心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<p v-if="!nodes.length" class="empty">还没有节点。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 class="section-title">提现审核</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>单号</th><th>钱包</th><th>金额</th><th>状态</th><th>链上哈希</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in withdrawals" :key="row.id">
|
||||||
|
<td>#{{ row.id }}</td>
|
||||||
|
<td class="mono">{{ row.wallet_address }}</td>
|
||||||
|
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
|
||||||
|
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
|
||||||
|
<td>
|
||||||
|
<input v-if="row.status === 'pending'" v-model="hashes[row.id]" placeholder="交易哈希" />
|
||||||
|
<span v-else class="mono">{{ row.tx_hash || "—" }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions" v-if="row.status === 'pending'">
|
||||||
|
<button class="gold" type="button" @click="confirm(row)">打款</button>
|
||||||
|
<button class="texty" type="button" @click="reject(row)">拒绝</button>
|
||||||
|
</td>
|
||||||
|
<td v-else></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!withdrawals.length"><td colspan="6" class="empty">还没有提现申请。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">用户</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>钱包</th><th>用户名</th><th>角色</th><th>节点</th><th>可用积分</th><th>注册</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in users" :key="user.id">
|
||||||
|
<td class="mono">{{ user.wallet_address }}</td>
|
||||||
|
<td>{{ user.username || "—" }}</td>
|
||||||
|
<td>{{ statusText[user.role] }}</td>
|
||||||
|
<td>{{ user.node_count }}</td>
|
||||||
|
<td>{{ money(user.available_points) }}</td>
|
||||||
|
<td>{{ when(user.created_at) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import Shell from "../components/Shell.vue";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { money, pct, statusText, when } from "../format";
|
||||||
|
|
||||||
|
const board = ref(null);
|
||||||
|
const error = ref("");
|
||||||
|
const notice = ref("");
|
||||||
|
const nodeForm = ref({ sn: "", purchase_amount: "", callback_url: "" });
|
||||||
|
const cashForm = ref({ amount: "", asset_symbol: "POINT" });
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
board.value = await api("/me/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(work, done) {
|
||||||
|
error.value = "";
|
||||||
|
notice.value = "";
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
notice.value = done;
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindNode() {
|
||||||
|
const body = {
|
||||||
|
sn: nodeForm.value.sn.trim(),
|
||||||
|
purchase_amount: nodeForm.value.purchase_amount,
|
||||||
|
};
|
||||||
|
if (nodeForm.value.callback_url.trim()) body.callback_url = nodeForm.value.callback_url.trim();
|
||||||
|
return run(async () => {
|
||||||
|
await api("/me/nodes", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
nodeForm.value = { sn: "", purchase_amount: "", callback_url: "" };
|
||||||
|
}, "节点已登记");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCash() {
|
||||||
|
return run(async () => {
|
||||||
|
await api("/me/withdrawals", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount: cashForm.value.amount,
|
||||||
|
asset_symbol: cashForm.value.asset_symbol || "POINT",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
cashForm.value.amount = "";
|
||||||
|
}, "提现申请已提交");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message;
|
||||||
|
}
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
load().catch((err) => {
|
||||||
|
error.value = err.message;
|
||||||
|
});
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => window.clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Shell eyebrow="用户控制台" title="我的算力">
|
||||||
|
<p v-if="error" class="form-error banner">{{ error }}</p>
|
||||||
|
<p v-if="notice" class="banner ok">{{ notice }}</p>
|
||||||
|
<section v-if="board" class="metrics">
|
||||||
|
<article class="metric">
|
||||||
|
<span>可用积分</span>
|
||||||
|
<strong>{{ money(board.available_points) }}</strong>
|
||||||
|
<em>已入账 {{ money(board.settled_points) }}</em>
|
||||||
|
</article>
|
||||||
|
<article class="metric">
|
||||||
|
<span>今日预计</span>
|
||||||
|
<strong>{{ money(board.today_points) }}</strong>
|
||||||
|
<em>按当前在线实时估算</em>
|
||||||
|
</article>
|
||||||
|
<article class="metric">
|
||||||
|
<span>节点</span>
|
||||||
|
<strong>{{ board.nodes.length }}</strong>
|
||||||
|
<em>{{ board.nodes.filter((node) => node.status === "online").length }} 台在线</em>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="board">
|
||||||
|
<article v-for="node in board?.nodes || []" :key="node.sn" class="plate">
|
||||||
|
<header>
|
||||||
|
<strong>{{ node.sn }}</strong>
|
||||||
|
<span class="pill" :class="node.status"><i />{{ statusText[node.status] }}</span>
|
||||||
|
</header>
|
||||||
|
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>购买金额</dt><dd>{{ money(node.purchase_amount) }}</dd></div>
|
||||||
|
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
|
||||||
|
<div><dt>最近心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
<p v-if="board && !board.nodes.length" class="empty">还没有节点。下面可以登记第一台。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="forms">
|
||||||
|
<form class="panel" @submit.prevent="bindNode">
|
||||||
|
<h2>登记节点</h2>
|
||||||
|
<p class="sub">绑到当前钱包,授权周期 12 个月。</p>
|
||||||
|
<label>序列号<input v-model="nodeForm.sn" required minlength="4" placeholder="NODE-001" /></label>
|
||||||
|
<label>购买金额<input v-model="nodeForm.purchase_amount" required placeholder="365" /></label>
|
||||||
|
<label>回调地址<input v-model="nodeForm.callback_url" placeholder="https:// 可选" /></label>
|
||||||
|
<button class="gold" type="submit">登记</button>
|
||||||
|
</form>
|
||||||
|
<form class="panel" @submit.prevent="requestCash">
|
||||||
|
<h2>申请提现</h2>
|
||||||
|
<p class="sub">提交后金额会被占用,等管理员打款。</p>
|
||||||
|
<label>金额<input v-model="cashForm.amount" required placeholder="1.25" /></label>
|
||||||
|
<label>资产<input v-model="cashForm.asset_symbol" /></label>
|
||||||
|
<button class="gold" type="submit">提交申请</button>
|
||||||
|
<ul class="mini">
|
||||||
|
<li v-for="row in board?.withdrawals || []" :key="row.id">
|
||||||
|
<span>#{{ row.id }} · {{ money(row.amount) }} {{ row.asset_symbol }}</span>
|
||||||
|
<span class="pill" :class="row.status">{{ statusText[row.status] }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</Shell>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { api, saveSession } from "../api";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const panel = ref("login");
|
||||||
|
const method = ref("wallet");
|
||||||
|
const error = ref("");
|
||||||
|
const pending = ref(false);
|
||||||
|
const form = ref({
|
||||||
|
wallet_address: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
confirm: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = "";
|
||||||
|
if (panel.value === "register" && form.value.password !== form.value.confirm) {
|
||||||
|
error.value = "两次密码不一致";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.value = true;
|
||||||
|
try {
|
||||||
|
const payload = panel.value === "register" ? await register() : await login();
|
||||||
|
saveSession(payload);
|
||||||
|
router.push(payload.role === "admin" ? "/admin" : "/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message;
|
||||||
|
} finally {
|
||||||
|
pending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
if (method.value === "wallet") {
|
||||||
|
return api("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ wallet_address: form.value.wallet_address.trim() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return api("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: form.value.username.trim(),
|
||||||
|
password: form.value.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
return api("/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
wallet_address: form.value.wallet_address.trim(),
|
||||||
|
username: form.value.username.trim(),
|
||||||
|
password: form.value.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="gate">
|
||||||
|
<section class="gate-copy">
|
||||||
|
<p class="eyebrow">HyperNode</p>
|
||||||
|
<h1>算力在这里入账。</h1>
|
||||||
|
<p>钱包或账号进入自己的节点。管理员从这里审核提现、看全网在线。</p>
|
||||||
|
</section>
|
||||||
|
<form class="gate-card" @submit.prevent="submit">
|
||||||
|
<div class="switch">
|
||||||
|
<button type="button" :class="{ on: panel === 'login' }" @click="panel = 'login'">登录</button>
|
||||||
|
<button type="button" :class="{ on: panel === 'register' }" @click="panel = 'register'">注册</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="panel === 'login'" class="switch slim">
|
||||||
|
<button type="button" :class="{ on: method === 'wallet' }" @click="method = 'wallet'">钱包地址</button>
|
||||||
|
<button type="button" :class="{ on: method === 'account' }" @click="method = 'account'">账号密码</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label v-if="panel === 'register' || method === 'wallet'">
|
||||||
|
钱包地址
|
||||||
|
<input v-model="form.wallet_address" required minlength="4" placeholder="0xabc123" autocomplete="username" />
|
||||||
|
</label>
|
||||||
|
<label v-if="panel === 'register' || method === 'account'">
|
||||||
|
用户名
|
||||||
|
<input v-model="form.username" required minlength="3" maxlength="32" placeholder="miner" autocomplete="username" />
|
||||||
|
</label>
|
||||||
|
<label v-if="panel === 'register' || method === 'account'">
|
||||||
|
密码
|
||||||
|
<input v-model="form.password" required minlength="6" type="password" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<label v-if="panel === 'register'">
|
||||||
|
确认密码
|
||||||
|
<input v-model="form.confirm" required minlength="6" type="password" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<p v-if="error" class="form-error">{{ error }}</p>
|
||||||
|
<button class="gold wide" type="submit" :disabled="pending">{{ pending ? "请稍候" : panel === "login" ? "进入" : "创建账号" }}</button>
|
||||||
|
<p class="hint">管理员不能只用钱包进入,需要账号密码。</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
outDir: "../web",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:8000",
|
||||||
|
"/health": "http://127.0.0.1:8000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -6,3 +6,4 @@ pydantic>=2.6
|
|||||||
pydantic-settings>=2.2
|
pydantic-settings>=2.2
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
tzdata>=2024.1
|
tzdata>=2024.1
|
||||||
|
PyJWT>=2.8
|
||||||
|
|||||||
+77
-6
@@ -13,6 +13,10 @@ from pathlib import Path
|
|||||||
_db_path = Path(tempfile.gettempdir()) / "depin_core_flow.db"
|
_db_path = Path(tempfile.gettempdir()) / "depin_core_flow.db"
|
||||||
os.environ["DATABASE_URL"] = "sqlite:///" + _db_path.as_posix()
|
os.environ["DATABASE_URL"] = "sqlite:///" + _db_path.as_posix()
|
||||||
os.environ["DISABLE_SCHEDULER"] = "1"
|
os.environ["DISABLE_SCHEDULER"] = "1"
|
||||||
|
os.environ["JWT_SECRET"] = "test-secret-test-secret-test-secret"
|
||||||
|
os.environ["ADMIN_USERNAME"] = "admin"
|
||||||
|
os.environ["ADMIN_PASSWORD"] = "test-admin-pass"
|
||||||
|
os.environ["ADMIN_WALLET"] = "admin-root"
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -129,7 +133,11 @@ class CoreFlowTest(unittest.TestCase):
|
|||||||
session.add(self._task(miss.id, TaskStatus.FAILED, day_start))
|
session.add(self._task(miss.id, TaskStatus.FAILED, day_start))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
|
settled = self.client.post(
|
||||||
|
"/api/v1/yield/settle",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
params={"yield_date": yesterday.isoformat()},
|
||||||
|
)
|
||||||
self.assertEqual(settled.status_code, 200, settled.text)
|
self.assertEqual(settled.status_code, 200, settled.text)
|
||||||
self.assertEqual(settled.json()["created"], 3)
|
self.assertEqual(settled.json()["created"], 3)
|
||||||
|
|
||||||
@@ -153,11 +161,19 @@ class CoreFlowTest(unittest.TestCase):
|
|||||||
self.assertEqual(miss_row.points_delta, Decimal("0.70000000"))
|
self.assertEqual(miss_row.points_delta, Decimal("0.70000000"))
|
||||||
self.assertEqual(miss_row.compute_output, Decimal("255.50000000"))
|
self.assertEqual(miss_row.compute_output, Decimal("255.50000000"))
|
||||||
|
|
||||||
again = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
|
again = self.client.post(
|
||||||
|
"/api/v1/yield/settle",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
params={"yield_date": yesterday.isoformat()},
|
||||||
|
)
|
||||||
self.assertEqual(again.json()["created"], 0)
|
self.assertEqual(again.json()["created"], 0)
|
||||||
self.assertEqual(again.json()["skipped"], 3)
|
self.assertEqual(again.json()["skipped"], 3)
|
||||||
|
|
||||||
future = self.client.post("/api/v1/yield/settle", params={"yield_date": "2999-01-01"})
|
future = self.client.post(
|
||||||
|
"/api/v1/yield/settle",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
params={"yield_date": "2999-01-01"},
|
||||||
|
)
|
||||||
self.assertEqual(future.status_code, 400, future.text)
|
self.assertEqual(future.status_code, 400, future.text)
|
||||||
|
|
||||||
def test_open_interval_stops_at_last_heartbeat(self) -> None:
|
def test_open_interval_stops_at_last_heartbeat(self) -> None:
|
||||||
@@ -173,7 +189,11 @@ class CoreFlowTest(unittest.TestCase):
|
|||||||
session.add(UptimeInterval(node_id=node.id, started_at=day_start, ended_at=None))
|
session.add(UptimeInterval(node_id=node.id, started_at=day_start, ended_at=None))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
|
settled = self.client.post(
|
||||||
|
"/api/v1/yield/settle",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
params={"yield_date": yesterday.isoformat()},
|
||||||
|
)
|
||||||
self.assertEqual(settled.status_code, 200, settled.text)
|
self.assertEqual(settled.status_code, 200, settled.text)
|
||||||
row = self._yield_row("NODE-HALF")
|
row = self._yield_row("NODE-HALF")
|
||||||
self.assertEqual(row.online_seconds, 12 * 60 * 60)
|
self.assertEqual(row.online_seconds, 12 * 60 * 60)
|
||||||
@@ -205,17 +225,24 @@ class CoreFlowTest(unittest.TestCase):
|
|||||||
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
||||||
self._register("0xpay", "NODE-PAY", "365")
|
self._register("0xpay", "NODE-PAY", "365")
|
||||||
self._cover_full_day("NODE-PAY", day_start, day_start + timedelta(days=1))
|
self._cover_full_day("NODE-PAY", day_start, day_start + timedelta(days=1))
|
||||||
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
|
payer = self._auth("0xpay")
|
||||||
|
settled = self.client.post(
|
||||||
|
"/api/v1/yield/settle",
|
||||||
|
headers=self._admin_headers(),
|
||||||
|
params={"yield_date": yesterday.isoformat()},
|
||||||
|
)
|
||||||
self.assertEqual(settled.status_code, 200, settled.text)
|
self.assertEqual(settled.status_code, 200, settled.text)
|
||||||
|
|
||||||
too_much = self.client.post(
|
too_much = self.client.post(
|
||||||
"/api/v1/withdrawals",
|
"/api/v1/withdrawals",
|
||||||
|
headers=payer,
|
||||||
json={"wallet_address": "0xpay", "amount": "10"},
|
json={"wallet_address": "0xpay", "amount": "10"},
|
||||||
)
|
)
|
||||||
self.assertEqual(too_much.status_code, 400, too_much.text)
|
self.assertEqual(too_much.status_code, 400, too_much.text)
|
||||||
|
|
||||||
created = self.client.post(
|
created = self.client.post(
|
||||||
"/api/v1/withdrawals",
|
"/api/v1/withdrawals",
|
||||||
|
headers=payer,
|
||||||
json={"wallet_address": "0xpay", "amount": "0.4", "asset_symbol": "POINT"},
|
json={"wallet_address": "0xpay", "amount": "0.4", "asset_symbol": "POINT"},
|
||||||
)
|
)
|
||||||
self.assertEqual(created.status_code, 200, created.text)
|
self.assertEqual(created.status_code, 200, created.text)
|
||||||
@@ -224,26 +251,70 @@ class CoreFlowTest(unittest.TestCase):
|
|||||||
|
|
||||||
blocked = self.client.post(
|
blocked = self.client.post(
|
||||||
"/api/v1/withdrawals",
|
"/api/v1/withdrawals",
|
||||||
|
headers=payer,
|
||||||
json={"wallet_address": "0xpay", "amount": "0.7"},
|
json={"wallet_address": "0xpay", "amount": "0.7"},
|
||||||
)
|
)
|
||||||
self.assertEqual(blocked.status_code, 400, blocked.text)
|
self.assertEqual(blocked.status_code, 400, blocked.text)
|
||||||
|
|
||||||
confirmed = self.client.post(
|
confirmed = self.client.post(
|
||||||
f"/api/v1/withdrawals/{withdrawal_id}/confirm",
|
f"/api/v1/withdrawals/{withdrawal_id}/confirm",
|
||||||
|
headers=self._admin_headers(),
|
||||||
json={"tx_hash": "0xabc123"},
|
json={"tx_hash": "0xabc123"},
|
||||||
)
|
)
|
||||||
self.assertEqual(confirmed.status_code, 200, confirmed.text)
|
self.assertEqual(confirmed.status_code, 200, confirmed.text)
|
||||||
self.assertEqual(confirmed.json()["status"], "completed")
|
self.assertEqual(confirmed.json()["status"], "completed")
|
||||||
self.assertEqual(confirmed.json()["tx_hash"], "0xabc123")
|
self.assertEqual(confirmed.json()["tx_hash"], "0xabc123")
|
||||||
|
|
||||||
balance = self.client.get("/api/v1/users/0xpay/balance")
|
balance = self.client.get("/api/v1/users/0xpay/balance", headers=self._auth("0xpay"))
|
||||||
self.assertEqual(balance.json()["available_points"], "0.60000000")
|
self.assertEqual(balance.json()["available_points"], "0.60000000")
|
||||||
|
|
||||||
|
def test_roles_split_user_and_admin(self) -> None:
|
||||||
|
registered = self.client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"wallet_address": "0xuser01", "username": "miner", "password": "secret1"},
|
||||||
|
)
|
||||||
|
self.assertEqual(registered.status_code, 200, registered.text)
|
||||||
|
self.assertEqual(registered.json()["role"], "user")
|
||||||
|
token = registered.json()["access_token"]
|
||||||
|
user_headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
by_wallet = self.client.post("/api/v1/auth/login", json={"wallet_address": "0xuser01"})
|
||||||
|
self.assertEqual(by_wallet.status_code, 200, by_wallet.text)
|
||||||
|
by_password = self.client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "miner", "password": "secret1"},
|
||||||
|
)
|
||||||
|
self.assertEqual(by_password.status_code, 200, by_password.text)
|
||||||
|
|
||||||
|
denied = self.client.get("/api/v1/admin/users", headers=user_headers)
|
||||||
|
self.assertEqual(denied.status_code, 403, denied.text)
|
||||||
|
opened = self.client.get("/api/v1/admin/summary", headers=self._admin_headers())
|
||||||
|
self.assertEqual(opened.status_code, 200, opened.text)
|
||||||
|
self.assertGreaterEqual(opened.json()["users"], 1)
|
||||||
|
|
||||||
|
wallet_admin = self.client.post("/api/v1/auth/login", json={"wallet_address": "admin-root"})
|
||||||
|
self.assertEqual(wallet_admin.status_code, 403, wallet_admin.text)
|
||||||
|
|
||||||
|
def _auth(self, wallet: str) -> dict[str, str]:
|
||||||
|
logged = self.client.post("/api/v1/auth/login", json={"wallet_address": wallet})
|
||||||
|
self.assertEqual(logged.status_code, 200, logged.text)
|
||||||
|
return {"Authorization": f"Bearer {logged.json()['access_token']}"}
|
||||||
|
|
||||||
|
def _admin_headers(self) -> dict[str, str]:
|
||||||
|
logged = self.client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "admin", "password": "test-admin-pass"},
|
||||||
|
)
|
||||||
|
self.assertEqual(logged.status_code, 200, logged.text)
|
||||||
|
self.assertEqual(logged.json()["role"], "admin")
|
||||||
|
return {"Authorization": f"Bearer {logged.json()['access_token']}"}
|
||||||
|
|
||||||
def _register(self, wallet: str, sn: str, amount: str) -> None:
|
def _register(self, wallet: str, sn: str, amount: str) -> None:
|
||||||
user = self.client.post("/api/v1/users", json={"wallet_address": wallet})
|
user = self.client.post("/api/v1/users", json={"wallet_address": wallet})
|
||||||
self.assertEqual(user.status_code, 200, user.text)
|
self.assertEqual(user.status_code, 200, user.text)
|
||||||
node = self.client.post(
|
node = self.client.post(
|
||||||
"/api/v1/nodes",
|
"/api/v1/nodes",
|
||||||
|
headers=self._auth(wallet),
|
||||||
json={"wallet_address": wallet, "sn": sn, "purchase_amount": amount},
|
json={"wallet_address": wallet, "sn": sn, "purchase_amount": amount},
|
||||||
)
|
)
|
||||||
self.assertEqual(node.status_code, 200, node.text)
|
self.assertEqual(node.status_code, 200, node.text)
|
||||||
|
|||||||
-498
@@ -1,498 +0,0 @@
|
|||||||
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]));
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
-60
@@ -1,60 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>HyperNode · 算力控制面</title>
|
<title>HyperNode · 算力控制面</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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 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" />
|
<script type="module" crossorigin src="/assets/index-CZ0L8T-X.js"></script>
|
||||||
</head>
|
<link rel="stylesheet" crossorigin href="/assets/index-mWM9aOGS.css">
|
||||||
<body>
|
</head>
|
||||||
<div class="shell">
|
<body>
|
||||||
<aside class="rail">
|
<div id="app"></div>
|
||||||
<a class="brand" href="#overview">
|
|
||||||
<span class="mark" aria-hidden="true">
|
</body>
|
||||||
<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
@@ -1,359 +0,0 @@
|
|||||||
: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,14 +23,15 @@ 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/` | 控制台页面,服务启动后打开 `/` |
|
| `frontend/` | Vue 3 控制台源码。改完后在该目录执行 `npm run build` |
|
||||||
|
| `web/` | 控制台构建结果,服务启动后打开 `/` |
|
||||||
| `deploy/manage.sh` | Ubuntu 22.04 交互式部署,支持 `curl \| bash` |
|
| `deploy/manage.sh` | Ubuntu 22.04 交互式部署,支持 `curl \| bash` |
|
||||||
|
|
||||||
## 数据表
|
## 数据表
|
||||||
|
|
||||||
业务表:
|
业务表:
|
||||||
|
|
||||||
- `users`:钱包地址、注册时间。
|
- `users`:钱包地址、可选用户名和密码、角色 `user` / `admin`、注册时间。
|
||||||
- `compute_nodes`:节点 SN、购买金额、12 个月授权、`online` / `offline`、最后心跳。可选 `callback_url` 用来推送任务。
|
- `compute_nodes`:节点 SN、购买金额、12 个月授权、`online` / `offline`、最后心跳。可选 `callback_url` 用来推送任务。
|
||||||
- `node_yields`:每日算力产出和积分。同一节点同一天只有一条。
|
- `node_yields`:每日算力产出和积分。同一节点同一天只有一条。
|
||||||
- `withdrawal_logs`:提现金额、资产符号、链上哈希、状态。
|
- `withdrawal_logs`:提现金额、资产符号、链上哈希、状态。
|
||||||
@@ -66,14 +67,43 @@ python -m app
|
|||||||
|
|
||||||
回传必须带上任务里的 `nonce`。超时、重复回传、nonce 不匹配都不算成功。
|
回传必须带上任务里的 `nonce`。超时、重复回传、nonce 不匹配都不算成功。
|
||||||
|
|
||||||
|
## 登录
|
||||||
|
|
||||||
|
普通用户可以注册钱包、用户名和密码。之后用钱包地址,或用户名加密码登录。管理员只能用账号密码。
|
||||||
|
|
||||||
|
新部署会在安装结束时打印管理员账号 `admin` 和随机密码,并写入 `/opt/hypernode/.env` 的 `ADMIN_PASSWORD`。已经部署过的机器如果 `.env` 里没有这两项,服务第一次启动会在日志里写一次初始密码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u hypernode.service | grep 初始密码
|
||||||
|
```
|
||||||
|
|
||||||
|
页面路由:
|
||||||
|
|
||||||
|
- `/login` 登录和注册
|
||||||
|
- `/dashboard` 用户自己的节点、今日预计积分、提现申请
|
||||||
|
- `/admin` 管理员看总用户、总节点、总产出、节点在线率和提现审核
|
||||||
|
|
||||||
|
节点心跳、拉任务、回传任务不需要登录。查看全网数据、清算、确认提现需要管理员令牌。
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
前缀都是 `/api/v1`。金额在 JSON 里是字符串。
|
前缀都是 `/api/v1`。金额在 JSON 里是字符串。浏览器登录后带 `Authorization: Bearer <token>`。
|
||||||
|
|
||||||
| 方法 | 路径 | 作用 |
|
| 方法 | 路径 | 作用 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/users` | 注册钱包 |
|
| POST | `/auth/register` | 注册钱包,可带用户名和密码,返回令牌 |
|
||||||
| POST | `/nodes` | 登记节点 |
|
| POST | `/auth/login` | 钱包地址,或用户名加密码 |
|
||||||
|
| GET | `/me/dashboard` | 当前用户的节点、积分和提现 |
|
||||||
|
| POST | `/me/nodes` | 给当前钱包登记节点 |
|
||||||
|
| POST | `/me/withdrawals` | 当前用户申请提现 |
|
||||||
|
| GET | `/admin/summary` | 管理员:用户数、节点数、总产出 |
|
||||||
|
| GET | `/admin/users` | 管理员:全网用户 |
|
||||||
|
| GET | `/admin/nodes` | 管理员:节点状态与今日在线率 |
|
||||||
|
| GET | `/admin/withdrawals` | 管理员:提现审核列表 |
|
||||||
|
| POST | `/admin/withdrawals/{id}/confirm` | 管理员:写入链上哈希并完成 |
|
||||||
|
| POST | `/admin/withdrawals/{id}/reject` | 管理员:拒绝并释放占用的积分 |
|
||||||
|
| POST | `/users` | 只登记钱包,不设置密码 |
|
||||||
|
| POST | `/nodes` | 登记节点,必须是该钱包本人或管理员 |
|
||||||
| GET | `/nodes/{sn}` | 查看节点 |
|
| GET | `/nodes/{sn}` | 查看节点 |
|
||||||
| POST | `/node/heartbeat` | 心跳,请求体 `{"sn":"..."}` |
|
| POST | `/node/heartbeat` | 心跳,请求体 `{"sn":"..."}` |
|
||||||
| GET | `/node/{sn}/tasks` | 拉取未完成任务 |
|
| GET | `/node/{sn}/tasks` | 拉取未完成任务 |
|
||||||
@@ -107,5 +137,10 @@ python -m app
|
|||||||
| `YIELD_HOUR` / `YIELD_MINUTE` | `0` / `5` | 清算时刻 |
|
| `YIELD_HOUR` / `YIELD_MINUTE` | `0` / `5` | 清算时刻 |
|
||||||
| `LICENSE_MONTHS` | `12` | 新节点授权月数 |
|
| `LICENSE_MONTHS` | `12` | 新节点授权月数 |
|
||||||
| `DISABLE_SCHEDULER` | `false` | 设为 `true` 时不启动定时任务 |
|
| `DISABLE_SCHEDULER` | `false` | 设为 `true` 时不启动定时任务 |
|
||||||
|
| `JWT_SECRET` | 空则自动生成并写入数据库 | 登录令牌签名 |
|
||||||
|
| `JWT_EXPIRE_MINUTES` | `1440` | 令牌有效分钟数 |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | 初始管理员用户名 |
|
||||||
|
| `ADMIN_PASSWORD` | 空则首次启动随机生成并写入日志 | 初始管理员密码 |
|
||||||
|
| `ADMIN_WALLET` | `admin-root` | 初始管理员钱包地址 |
|
||||||
|
|
||||||
修改 `.env` 后执行 `systemctl restart hypernode`。
|
修改 `.env` 后执行 `systemctl restart hypernode`。
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ journalctl -u hypernode.service -f
|
|||||||
curl http://127.0.0.1:33333/health
|
curl http://127.0.0.1:33333/health
|
||||||
```
|
```
|
||||||
|
|
||||||
控制台:`http://<服务器 IP>:33333/`
|
控制台:`http://<服务器 IP>:33333/`。首次部署结束时会打印管理员账号和密码;已经部署过的机器,密码在第一次更新后的服务日志里,搜索「初始密码」。
|
||||||
|
|
||||||
接口文档:`http://<服务器 IP>:33333/docs`
|
接口文档:`http://<服务器 IP>:33333/docs`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user