diff --git a/app/api.py b/app/api.py
index 2b4cf01..d168298 100644
--- a/app/api.py
+++ b/app/api.py
@@ -72,6 +72,7 @@ from app.services.common import get_node_by_sn, get_user_by_wallet
from app.services.datacenter import approve_application, list_applications, reject_application, submit_application
from app.services.hardware import HardwareReport
from app.services.hardware import display_node_id, displayed_population
+from app.services.alpha_queue import assign_queued_alpha_task, maybe_generate_alpha_batch
from app.services.heartbeat import assert_node_token, record_heartbeat, set_node_banned
from app.services.ip_guard import add_whitelist_ip, client_ip, list_whitelist, remove_whitelist_ip
from app.services.orders import approve_order, list_orders, place_order, reject_order, render_client
@@ -327,6 +328,8 @@ def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut:
client_ip=client_ip(request),
hardware_fingerprint=body.hardware_fingerprint,
)
+ maybe_generate_alpha_batch(db)
+ assigned = assign_queued_alpha_task(db, node)
days = node.recovery_days or 540
return HeartbeatOut(
sn=node.sn,
@@ -340,6 +343,7 @@ def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut:
recoverable_stake_amount=node.recoverable_stake_amount,
purchase_amount=node.purchase_amount,
estimated_daily_points=money(node.purchase_amount / Decimal(days)),
+ assigned_task=_task_out(assigned) if assigned is not None else None,
)
@@ -514,8 +518,8 @@ def _admin_task_out(task: ComputeTask) -> AdminTaskOut:
return AdminTaskOut(
task_id=task.id,
task_type=task.task_type,
- target_node_sn=node.sn,
- display_node_id=node.display_node_id,
+ target_node_sn=node.sn if node is not None else "",
+ display_node_id=node.display_node_id if node is not None else 0,
status=task.status,
payload=task.payload or {},
result=task.result,
diff --git a/app/models.py b/app/models.py
index dede817..79e4be1 100644
--- a/app/models.py
+++ b/app/models.py
@@ -249,7 +249,7 @@ class ComputeTask(Base):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, comment="task_id")
- node_id: Mapped[int] = mapped_column(ForeignKey("compute_nodes.id"), index=True)
+ node_id: Mapped[int | None] = mapped_column(ForeignKey("compute_nodes.id"), nullable=True, index=True)
task_type: Mapped[str] = mapped_column(String(32), default="simulated_compute")
payload: Mapped[dict] = mapped_column(JSON)
status: Mapped[TaskStatus] = mapped_column(_enum(TaskStatus), default=TaskStatus.PENDING, index=True)
@@ -261,7 +261,26 @@ class ComputeTask(Base):
success: Mapped[bool | None] = mapped_column()
result: Mapped[dict | None] = mapped_column(JSON, comment="节点回传的任务结果")
- node: Mapped[ComputeNode] = relationship(back_populates="tasks")
+ node: Mapped[ComputeNode | None] = relationship(back_populates="tasks")
+
+
+class AlphaFactor(Base):
+ """节点回传且达到 IC 门槛的因子。"""
+
+ __tablename__ = "alpha_factors"
+ __table_args__ = (
+ Index("ix_alpha_factor_task", "task_id"),
+ Index("ix_alpha_factor_ic", "ic"),
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ task_id: Mapped[str] = mapped_column(ForeignKey("compute_tasks.id"), index=True)
+ node_id: Mapped[int] = mapped_column(ForeignKey("compute_nodes.id"), index=True)
+ formula: Mapped[str] = mapped_column(Text)
+ ic: Mapped[float] = mapped_column(Float)
+ symbol: Mapped[str | None] = mapped_column(String(32))
+ seed: Mapped[str | None] = mapped_column(String(64))
+ created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
class NodeYield(Base):
diff --git a/app/scheduler.py b/app/scheduler.py
index 75d204b..1aad168 100644
--- a/app/scheduler.py
+++ b/app/scheduler.py
@@ -13,6 +13,7 @@ from zoneinfo import ZoneInfo
from app.config import settings
from app.database import session_scope
from app.jobs.daily_yield import run_daily_yield_cron
+from app.services.alpha_queue import maybe_generate_alpha_batch
from app.services.heartbeat import mark_stale_nodes
from app.services.locks import acquire_lock
from app.services.tasks import dispatch_random_tasks
@@ -45,6 +46,13 @@ def start_scheduler() -> None:
replace_existing=True,
misfire_grace_time=120,
)
+ scheduler.add_job(
+ _guard(_alpha_job),
+ IntervalTrigger(hours=2, timezone=UTC),
+ id="alpha_batch",
+ replace_existing=True,
+ misfire_grace_time=600,
+ )
yield_zone = ZoneInfo(settings.yield_timezone)
scheduler.add_job(
_guard(_yield_job),
@@ -98,6 +106,20 @@ def _dispatch_job() -> None:
tasks = dispatch_random_tasks(session)
if tasks:
logger.info("dispatched %s tasks", len(tasks))
+ _refill_alpha_queue(force=False)
+
+
+def _alpha_job() -> None:
+ _refill_alpha_queue(force=True)
+
+
+def _refill_alpha_queue(force: bool) -> None:
+ if not acquire_lock("alpha_batch", timedelta(minutes=5)):
+ return
+ with session_scope() as session:
+ created = maybe_generate_alpha_batch(session, force=force)
+ if created:
+ logger.info("alpha batch slices=%s force=%s", len(created), force)
def _yield_job() -> None:
diff --git a/app/schema_upgrade.py b/app/schema_upgrade.py
index 048f371..ebc00a6 100644
--- a/app/schema_upgrade.py
+++ b/app/schema_upgrade.py
@@ -73,15 +73,15 @@ def upgrade_schema() -> None:
"ON compute_nodes (hardware_fingerprint)"
)
index_ready = "compute_nodes" in tables and ("node_index" in node_columns or any("node_index" in item for item in statements))
- if not statements and not need_username_index and not index_ready:
- return
- with engine.begin() as conn:
- for statement in statements:
- conn.execute(text(statement))
- if need_username_index:
- conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_users_username ON users (username)"))
- if index_ready:
- _backfill_node_index(conn, "recovery_days" in node_columns or any("recovery_days" in item for item in statements))
+ if statements or need_username_index or index_ready:
+ with engine.begin() as conn:
+ for statement in statements:
+ conn.execute(text(statement))
+ if need_username_index:
+ conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_users_username ON users (username)"))
+ if index_ready:
+ _backfill_node_index(conn, "recovery_days" in node_columns or any("recovery_days" in item for item in statements))
+ _relax_task_node_id()
def _backfill_node_index(conn, has_recovery: bool) -> None:
@@ -97,6 +97,69 @@ def _backfill_node_index(conn, has_recovery: bool) -> None:
conn.execute(text("UPDATE compute_nodes SET recovery_days = 30 WHERE node_index BETWEEN 1 AND 20"))
+def _relax_task_node_id(bind=None) -> None:
+ """旧库 compute_tasks.node_id 为 NOT NULL,排队任务需要改成可空。SQLite 不能直接改空约束,整表重建。"""
+ target = bind or engine
+ inspector = inspect(target)
+ if "compute_tasks" not in inspector.get_table_names():
+ return
+ columns = {column["name"]: column for column in inspector.get_columns("compute_tasks")}
+ node_column = columns.get("node_id")
+ if node_column is None or node_column.get("nullable", True):
+ return
+ names = [
+ name
+ for name in (
+ "id",
+ "node_id",
+ "task_type",
+ "payload",
+ "status",
+ "push_status",
+ "push_error",
+ "dispatched_at",
+ "deadline_at",
+ "responded_at",
+ "success",
+ "result",
+ )
+ if name in columns
+ ]
+ copied = ", ".join(names)
+ with target.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
+ conn.execute(text("PRAGMA foreign_keys=OFF"))
+ conn.execute(text("DROP TABLE IF EXISTS compute_tasks_queue"))
+ conn.execute(
+ text(
+ """
+ CREATE TABLE compute_tasks_queue (
+ id VARCHAR(36) NOT NULL PRIMARY KEY,
+ node_id INTEGER,
+ task_type VARCHAR(32) NOT NULL,
+ payload JSON NOT NULL,
+ status VARCHAR(16) NOT NULL,
+ push_status VARCHAR(16) NOT NULL,
+ push_error VARCHAR(255),
+ dispatched_at DATETIME NOT NULL,
+ deadline_at DATETIME NOT NULL,
+ responded_at DATETIME,
+ success BOOLEAN,
+ result JSON,
+ FOREIGN KEY(node_id) REFERENCES compute_nodes (id)
+ )
+ """
+ )
+ )
+ conn.execute(text(f"INSERT INTO compute_tasks_queue ({copied}) SELECT {copied} FROM compute_tasks"))
+ conn.execute(text("DROP TABLE compute_tasks"))
+ conn.execute(text("ALTER TABLE compute_tasks_queue RENAME TO compute_tasks"))
+ conn.execute(text("CREATE INDEX IF NOT EXISTS ix_compute_tasks_node_id ON compute_tasks (node_id)"))
+ conn.execute(text("CREATE INDEX IF NOT EXISTS ix_compute_tasks_status ON compute_tasks (status)"))
+ conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_node_status ON compute_tasks (node_id, status)"))
+ conn.execute(text("CREATE INDEX IF NOT EXISTS ix_task_dispatched_at ON compute_tasks (dispatched_at)"))
+ conn.execute(text("PRAGMA foreign_keys=ON"))
+
+
def _has_username_index(inspector) -> bool:
if "users" not in inspector.get_table_names():
return False
diff --git a/app/schemas.py b/app/schemas.py
index 6b151b2..1b82c61 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -128,6 +128,7 @@ class HeartbeatOut(APIModel):
recoverable_stake_amount: MoneyText | None = None
purchase_amount: MoneyText | None = None
estimated_daily_points: MoneyText | None = None
+ assigned_task: TaskOut | None = None
class TaskOut(APIModel):
@@ -358,3 +359,4 @@ class DatacenterView(DatacenterOut):
DashboardOut.model_rebuild()
+HeartbeatOut.model_rebuild()
diff --git a/app/services/alpha_queue.py b/app/services/alpha_queue.py
new file mode 100644
index 0000000..ad4d6e5
--- /dev/null
+++ b/app/services/alpha_queue.py
@@ -0,0 +1,200 @@
+"""量化因子挖掘的排队与自动调度。
+
+没有待领取的 alpha_mining 任务时补一批;距上一批已满 2 小时时再补一批。
+切片先不绑定节点,心跳到来时再改成 processing。高 IC 因子写入 alpha_factors。
+"""
+
+from __future__ import annotations
+
+import random
+import secrets
+import uuid
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from app.clock import utcnow
+from app.config import settings
+from app.models import AlphaFactor, ComputeNode, ComputeTask, PushStatus, TaskStatus
+
+ALPHA_BATCH_HOURS = 2
+MIN_ABS_IC = 0.03
+SLICE_COUNT = 3
+QUEUE_HOLD_DAYS = 7
+OPERATORS = (
+ "ts_mean",
+ "ts_std",
+ "rank",
+ "delta",
+ "decay_linear",
+ "correlation",
+ "ts_rank",
+ "scale",
+)
+SYMBOLS = ("BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT")
+
+
+def maybe_generate_alpha_batch(
+ session: Session,
+ now: datetime | None = None,
+ force: bool = False,
+) -> list[ComputeTask]:
+ """队列里没有 pending 的挖掘任务,或距上一批已满 2 小时,就生成新的一批切片。"""
+ moment = now or utcnow()
+ pending = session.scalar(
+ select(func.count())
+ .select_from(ComputeTask)
+ .where(
+ ComputeTask.task_type == "alpha_mining",
+ ComputeTask.status == TaskStatus.PENDING,
+ )
+ ) or 0
+ latest = _as_utc(
+ session.scalar(select(func.max(ComputeTask.dispatched_at)).where(ComputeTask.task_type == "alpha_mining"))
+ )
+ due = latest is None or moment - latest >= timedelta(hours=ALPHA_BATCH_HOURS)
+ if pending and not due and not force:
+ return []
+ return _create_alpha_batch(session, moment)
+
+
+def assign_queued_alpha_task(
+ session: Session,
+ node: ComputeNode,
+ now: datetime | None = None,
+) -> ComputeTask | None:
+ """把最早的一条未绑定切片派给当前节点。节点已有执行中的挖掘任务时不再加派。"""
+ moment = now or utcnow()
+ holding = session.scalar(
+ select(ComputeTask.id).where(
+ ComputeTask.node_id == node.id,
+ ComputeTask.task_type == "alpha_mining",
+ ComputeTask.status == TaskStatus.PROCESSING,
+ )
+ )
+ if holding is not None:
+ return None
+ task = session.scalar(
+ select(ComputeTask)
+ .where(
+ ComputeTask.task_type == "alpha_mining",
+ ComputeTask.status == TaskStatus.PENDING,
+ ComputeTask.node_id.is_(None),
+ )
+ .order_by(ComputeTask.dispatched_at, ComputeTask.id)
+ .limit(1)
+ )
+ if task is None:
+ return None
+ task.node_id = node.id
+ task.status = TaskStatus.PROCESSING
+ task.deadline_at = moment + timedelta(minutes=settings.task_deadline_minutes)
+ session.flush()
+ return task
+
+
+def ingest_alpha_factors(session: Session, task: ComputeTask) -> list[AlphaFactor]:
+ """成功回传的挖掘结果里,只保存公式非空且绝对 IC 达到门槛的因子。"""
+ if task.task_type != "alpha_mining" or task.status != TaskStatus.COMPLETED or not task.success:
+ return []
+ if task.node_id is None:
+ return []
+ payload = task.payload or {}
+ saved: list[AlphaFactor] = []
+ for item in _factor_items(task.result or {}):
+ formula = str(item.get("formula") or item.get("alpha") or "").strip()
+ ic = _as_float(item.get("ic"))
+ if not formula or ic is None or abs(ic) < MIN_ABS_IC:
+ continue
+ symbol = item.get("symbol") or payload.get("symbol")
+ seed = payload.get("seed")
+ row = AlphaFactor(
+ task_id=task.id,
+ node_id=task.node_id,
+ formula=formula,
+ ic=ic,
+ symbol=str(symbol) if symbol else None,
+ seed=None if seed is None else str(seed),
+ created_at=task.responded_at or utcnow(),
+ )
+ session.add(row)
+ saved.append(row)
+ if saved:
+ session.flush()
+ return saved
+
+
+def _create_alpha_batch(session: Session, moment: datetime) -> list[ComputeTask]:
+ seed = secrets.randbelow(2**31)
+ rng = random.Random(seed)
+ batch_id = uuid.uuid4().hex[:12]
+ combos = _operator_combos(rng, SLICE_COUNT)
+ symbols = rng.sample(SYMBOLS, k=SLICE_COUNT)
+ deadline = moment + timedelta(days=QUEUE_HOLD_DAYS)
+ tasks: list[ComputeTask] = []
+ for combo, symbol in zip(combos, symbols):
+ task = ComputeTask(
+ id=str(uuid.uuid4()),
+ node_id=None,
+ task_type="alpha_mining",
+ payload={
+ "batch_id": batch_id,
+ "seed": seed,
+ "operators": combo,
+ "symbol": symbol,
+ "nonce": secrets.token_hex(16),
+ },
+ status=TaskStatus.PENDING,
+ push_status=PushStatus.QUEUED,
+ dispatched_at=moment,
+ deadline_at=deadline,
+ )
+ session.add(task)
+ tasks.append(task)
+ session.flush()
+ return tasks
+
+
+def _operator_combos(rng: random.Random, count: int) -> list[list[str]]:
+ combos: list[list[str]] = []
+ seen: set[tuple[str, ...]] = set()
+ while len(combos) < count:
+ size = 2 if rng.random() < 0.7 else 3
+ combo = tuple(sorted(rng.sample(OPERATORS, k=size)))
+ if combo in seen:
+ continue
+ seen.add(combo)
+ combos.append(list(combo))
+ return combos
+
+
+def _factor_items(result: dict) -> list[dict]:
+ factors = result.get("factors")
+ if isinstance(factors, list):
+ return [item for item in factors if isinstance(item, dict)]
+ if any(key in result for key in ("formula", "alpha", "ic")):
+ return [result]
+ return []
+
+
+def _as_utc(value: datetime | str | None) -> datetime | None:
+ if value is None:
+ return None
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(value, str) else value
+ if parsed.tzinfo is None:
+ return parsed.replace(tzinfo=timezone.utc)
+ return parsed
+
+
+def _as_float(value: object) -> float | None:
+ if isinstance(value, bool) or value is None:
+ return None
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value)
+ except ValueError:
+ return None
+ return None
diff --git a/app/services/tasks.py b/app/services/tasks.py
index 5751c07..ea8ee69 100644
--- a/app/services/tasks.py
+++ b/app/services/tasks.py
@@ -24,6 +24,7 @@ from app.clock import utcnow
from app.config import settings
from app.errors import AppError
from app.models import ComputeNode, ComputeTask, NodeStatus, PushStatus, TaskStatus
+from app.services.alpha_queue import ingest_alpha_factors
from app.services.common import get_node_by_sn
logger = logging.getLogger(__name__)
@@ -125,8 +126,15 @@ def dispatch_random_tasks(session: Session, now: datetime | None = None) -> list
"""向当前在线、授权仍有效、且没有未完成任务的节点随机下发一轮任务。"""
moment = now or utcnow()
expire_overdue_tasks(session, moment)
+ # 未绑定的挖掘切片不算占用节点。已派发的挖掘任务单独排队,不挡住模拟任务。
pending_node_ids = set(
- session.scalars(select(ComputeTask.node_id).where(ComputeTask.status.in_(_BUSY))).all()
+ session.scalars(
+ select(ComputeTask.node_id).where(
+ ComputeTask.node_id.is_not(None),
+ ComputeTask.status.in_(_BUSY),
+ ComputeTask.task_type != "alpha_mining",
+ )
+ ).all()
)
candidates = [
node
@@ -174,15 +182,16 @@ def list_pending_tasks(session: Session, sn: str, now: datetime | None = None) -
node = get_node_by_sn(session, sn.strip())
tasks = session.scalars(
select(ComputeTask)
- .where(ComputeTask.node_id == node.id, ComputeTask.status == TaskStatus.PENDING)
- .order_by(ComputeTask.dispatched_at)
+ .where(ComputeTask.node_id == node.id, ComputeTask.status.in_(_BUSY))
+ .order_by(ComputeTask.dispatched_at, ComputeTask.id)
).all()
visible: list[ComputeTask] = []
for task in tasks:
if task.deadline_at < moment:
task.status = TaskStatus.EXPIRED
continue
- task.status = TaskStatus.PROCESSING
+ if task.status == TaskStatus.PENDING:
+ task.status = TaskStatus.PROCESSING
visible.append(task)
session.flush()
return visible
@@ -221,6 +230,8 @@ def report_task(
task.result = result or None
task.status = TaskStatus.COMPLETED if success else TaskStatus.FAILED
session.flush()
+ if task.status == TaskStatus.COMPLETED and task.task_type == "alpha_mining":
+ ingest_alpha_factors(session, task)
return task
@@ -228,6 +239,7 @@ def expire_overdue_tasks(session: Session, now: datetime | None = None) -> int:
moment = now or utcnow()
tasks = session.scalars(
select(ComputeTask).where(
+ ComputeTask.node_id.is_not(None),
ComputeTask.status.in_(_BUSY),
ComputeTask.deadline_at < moment,
)
diff --git a/frontend/src/views/AdminOverview.vue b/frontend/src/views/AdminOverview.vue
index fcd26f2..4310740 100644
--- a/frontend/src/views/AdminOverview.vue
+++ b/frontend/src/views/AdminOverview.vue
@@ -483,7 +483,7 @@ onUnmounted(() => window.clearInterval(timer));
| {{ row.task_id.slice(0, 8) }} |
{{ taskTypeText(row.task_type) }} |
- {{ row.display_node_id || "—" }} · {{ row.target_node_sn }} |
+ {{ row.target_node_sn ? `${row.display_node_id || "—"} · ${row.target_node_sn}` : "排队中" }} |
{{ taskStatusText[row.status] || row.status }} |
{{ when(row.responded_at) }} |
{{ row.result_preview || "—" }} |
diff --git a/tests/test_core_flow.py b/tests/test_core_flow.py
index 40b6c7a..0d2ad3d 100644
--- a/tests/test_core_flow.py
+++ b/tests/test_core_flow.py
@@ -21,7 +21,7 @@ os.environ["ADMIN_PASSWORD"] = "test-admin-pass"
os.environ["ADMIN_WALLET"] = "admin-root"
from fastapi.testclient import TestClient
-from sqlalchemy import select
+from sqlalchemy import create_engine, select, text
from app.clock import utcnow
from app.database import Base, SessionLocal, engine
@@ -29,6 +29,7 @@ from app.main import app
from app.jobs.daily_yield import run_daily_yield_cron
from app.money import money
from app.models import (
+ AlphaFactor,
ComputeNode,
ComputeTask,
IPWhitelist,
@@ -42,7 +43,9 @@ from app.models import (
from app.services.heartbeat import mark_stale_nodes
from app.services.datacenter import FINGERPRINT_CLASH
from app.services.ip_guard import DATACENTER_BATCH_MESSAGE
-from app.services.tasks import dispatch_random_tasks
+from app.schema_upgrade import _relax_task_node_id
+from app.services.alpha_queue import maybe_generate_alpha_batch
+from app.services.tasks import dispatch_random_tasks, expire_overdue_tasks
class CoreFlowTest(unittest.TestCase):
@@ -197,7 +200,7 @@ class CoreFlowTest(unittest.TestCase):
pending = self.client.get("/api/v1/node/NODE-T/tasks")
self.assertEqual(pending.status_code, 200, pending.text)
- self.assertEqual(pending.json()[0]["task_id"], task_id)
+ self.assertTrue(any(row["task_id"] == task_id for row in pending.json()))
rejected = self.client.post(
"/api/v1/node/task/report",
@@ -763,6 +766,187 @@ class CoreFlowTest(unittest.TestCase):
self.assertEqual(logged.json()["role"], "admin")
return {"Authorization": f"Bearer {logged.json()['access_token']}"}
+ def test_alpha_queue_generates_assigns_and_keeps_high_ic(self) -> None:
+ self._register("0xalpha", "NODE-ALPHA", "100")
+ start = utcnow()
+ with SessionLocal() as session:
+ first = maybe_generate_alpha_batch(session, now=start)
+ session.commit()
+ self.assertEqual(len(first), 3)
+ self.assertEqual(len({task.payload["batch_id"] for task in first}), 1)
+ for task in first:
+ self.assertIsNone(task.node_id)
+ self.assertEqual(task.status, TaskStatus.PENDING)
+ self.assertEqual(task.task_type, "alpha_mining")
+ self.assertIsInstance(task.payload["seed"], int)
+ self.assertTrue(task.payload["operators"])
+ self.assertTrue(task.payload["symbol"])
+ self.assertGreaterEqual(len(task.payload["nonce"]), 8)
+ quiet = maybe_generate_alpha_batch(session, now=start + timedelta(hours=1))
+ self.assertEqual(quiet, [])
+ later = maybe_generate_alpha_batch(session, now=start + timedelta(hours=2))
+ session.commit()
+ self.assertEqual(len(later), 3)
+ self.assertNotEqual(later[0].payload["batch_id"], first[0].payload["batch_id"])
+ queued = session.get(ComputeTask, first[0].id)
+ queued.deadline_at = start - timedelta(minutes=1)
+ session.commit()
+ self.assertEqual(expire_overdue_tasks(session, start), 0)
+ self.assertEqual(session.get(ComputeTask, first[0].id).status, TaskStatus.PENDING)
+
+ listed = self.client.get("/api/v1/admin/tasks/list", headers=self._admin_headers())
+ self.assertEqual(listed.status_code, 200, listed.text)
+ queued_row = next(row for row in listed.json() if row["task_id"] == first[0].id)
+ self.assertEqual(queued_row["target_node_sn"], "")
+ self.assertEqual(queued_row["display_node_id"], 0)
+
+ beat = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"})
+ self.assertEqual(beat.status_code, 200, beat.text)
+ assigned = beat.json()["assigned_task"]
+ self.assertIsNotNone(assigned)
+ self.assertEqual(assigned["status"], "processing")
+ self.assertEqual(assigned["task_type"], "alpha_mining")
+ again = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"})
+ self.assertIsNone(again.json()["assigned_task"])
+ with SessionLocal() as session:
+ node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-ALPHA"))
+ holding = session.scalars(
+ select(ComputeTask).where(
+ ComputeTask.node_id == node.id,
+ ComputeTask.task_type == "alpha_mining",
+ ComputeTask.status == TaskStatus.PROCESSING,
+ )
+ ).all()
+ self.assertEqual(len(holding), 1)
+
+ high = self.client.post(
+ "/api/v1/node/task/report",
+ json={
+ "sn": "NODE-ALPHA",
+ "task_id": assigned["task_id"],
+ "success": True,
+ "nonce": assigned["payload"]["nonce"],
+ "result": {
+ "factors": [
+ {"formula": "rank(delta(close, 1))", "ic": 0.08},
+ {"formula": "scale(ts_std(close, 5))", "ic": 0.01},
+ {"alpha": "", "ic": 0.2},
+ ]
+ },
+ },
+ )
+ self.assertEqual(high.status_code, 200, high.text)
+ self.assertEqual(high.json()["status"], "completed")
+ with SessionLocal() as session:
+ rows = session.scalars(select(AlphaFactor)).all()
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0].formula, "rank(delta(close, 1))")
+ self.assertAlmostEqual(rows[0].ic, 0.08)
+ self.assertEqual(rows[0].symbol, assigned["payload"]["symbol"])
+
+ follow = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"})
+ second = follow.json()["assigned_task"]
+ self.assertIsNotNone(second)
+ self.assertNotEqual(second["task_id"], assigned["task_id"])
+ low = self.client.post(
+ "/api/v1/node/task/report",
+ json={
+ "sn": "NODE-ALPHA",
+ "task_id": second["task_id"],
+ "success": True,
+ "nonce": second["payload"]["nonce"],
+ "result": {"formula": "ts_mean(close, 3)", "ic": 0.01},
+ },
+ )
+ self.assertEqual(low.status_code, 200, low.text)
+ self.assertEqual(low.json()["status"], "completed")
+ with SessionLocal() as session:
+ self.assertEqual(len(session.scalars(select(AlphaFactor)).all()), 1)
+ self.assertEqual(session.get(ComputeTask, second["task_id"]).status, TaskStatus.COMPLETED)
+
+ follow_again = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-ALPHA"})
+ third = follow_again.json()["assigned_task"]
+ self.assertIsNotNone(third)
+ visible = self.client.get("/api/v1/node/NODE-ALPHA/tasks")
+ self.assertEqual(
+ [row for row in visible.json() if row["task_id"] == third["task_id"]][0]["status"],
+ "processing",
+ )
+ failed = self.client.post(
+ "/api/v1/node/task/report",
+ json={
+ "sn": "NODE-ALPHA",
+ "task_id": third["task_id"],
+ "success": False,
+ "nonce": third["payload"]["nonce"],
+ "result": {"formula": "rank(close)", "ic": 0.2},
+ },
+ )
+ self.assertEqual(failed.status_code, 200, failed.text)
+ self.assertEqual(failed.json()["status"], "failed")
+ with SessionLocal() as session:
+ self.assertEqual(len(session.scalars(select(AlphaFactor)).all()), 1)
+
+ def test_old_task_table_accepts_unassigned_rows(self) -> None:
+ path = Path(tempfile.gettempdir()) / "hypernode_relax_node.db"
+ if path.exists():
+ path.unlink()
+ other = create_engine("sqlite:///" + path.as_posix())
+ with other.begin() as conn:
+ conn.execute(text("CREATE TABLE compute_nodes (id INTEGER PRIMARY KEY)"))
+ conn.execute(text("INSERT INTO compute_nodes (id) VALUES (1)"))
+ conn.execute(
+ text(
+ """
+ CREATE TABLE compute_tasks (
+ id VARCHAR(36) NOT NULL PRIMARY KEY,
+ node_id INTEGER NOT NULL,
+ task_type VARCHAR(32) NOT NULL,
+ payload JSON NOT NULL,
+ status VARCHAR(16) NOT NULL,
+ push_status VARCHAR(16) NOT NULL,
+ push_error VARCHAR(255),
+ dispatched_at DATETIME NOT NULL,
+ deadline_at DATETIME NOT NULL,
+ responded_at DATETIME,
+ success BOOLEAN,
+ result JSON
+ )
+ """
+ )
+ )
+ conn.execute(
+ text(
+ """
+ INSERT INTO compute_tasks (
+ id, node_id, task_type, payload, status, push_status, dispatched_at, deadline_at
+ ) VALUES (
+ 'kept-task', 1, 'simulated_compute', '{}', 'pending', 'queued',
+ '2026-10-01 00:00:00', '2026-10-01 00:10:00'
+ )
+ """
+ )
+ )
+ _relax_task_node_id(other)
+ with other.begin() as conn:
+ conn.execute(
+ text(
+ """
+ INSERT INTO compute_tasks (
+ id, node_id, task_type, payload, status, push_status, dispatched_at, deadline_at
+ ) VALUES (
+ 'queued-task', NULL, 'alpha_mining', '{}', 'pending', 'queued',
+ '2026-10-01 00:00:00', '2026-10-08 00:00:00'
+ )
+ """
+ )
+ )
+ kept = conn.execute(text("SELECT node_id FROM compute_tasks WHERE id = 'kept-task'")).scalar()
+ queued = conn.execute(text("SELECT node_id FROM compute_tasks WHERE id = 'queued-task'")).scalar()
+ other.dispose()
+ self.assertEqual(kept, 1)
+ self.assertIsNone(queued)
+
def _register(self, wallet: str, sn: str, amount: str, headers: dict[str, str] | None = None) -> None:
user = self.client.post("/api/v1/users", json={"wallet_address": wallet})
self.assertEqual(user.status_code, 200, user.text)
diff --git a/web/assets/index-Lxi5Kb1N.js b/web/assets/index-De4QqK0A.js
similarity index 96%
rename from web/assets/index-Lxi5Kb1N.js
rename to web/assets/index-De4QqK0A.js
index 02c9b17..4ebc1c1 100644
--- a/web/assets/index-Lxi5Kb1N.js
+++ b/web/assets/index-De4QqK0A.js
@@ -37,4 +37,4 @@
}`},{id:"stress_test",label:"简易压测",sample:`{
"duration_seconds": 60,
"matrix": 4096
-}`}],E=Z({task_type:"alpha_mining",target_node_sn:"",payloadText:b[0].sample}),P=Z(!1),G={pending:"待领取",processing:"执行中",completed:"已完成",failed:"失败",expired:"已过期"},j=Z({ip_address:"",note:""}),O=Z(""),k=Z(""),U=Z(!1),F=Z(null);let w=0,te=0;const R=je(()=>{const v=String(n.params.tab||"");return t.some(m=>m.id===v)?v:""});function Be(v){return v.gpu_generation==="40"?"40 系":v.gpu_generation==="30"?"30 系":"10 系及以下"}function Ke(v){const m=b.find(D=>D.id===v);return m?m.label:v==="simulated_compute"?"模拟计算":v}function tt(){if(P.value)return;const v=b.find(m=>m.id===E.value.task_type);E.value.payloadText=v?v.sample:"{}"}function pt(v){return v.gpu_model?v.vram_gb?`${v.gpu_model} · ${v.vram_gb} GB`:v.gpu_model:"未上报"}async function Ue(){l.value=await ae("/admin/summary")}async function Tt(v,m=!1){const g={orders:async()=>{u.value=await ae("/admin/orders")},nodes:async()=>{i.value=await ae("/admin/nodes")},tasks:async()=>{const[f,p]=await Promise.all([ae("/admin/tasks/list"),ae("/admin/nodes")]);y.value=f,i.value=p},whitelist:async()=>{c.value=await ae("/admin/ip-whitelist")},"datacenter-apps":async()=>{h.value=await ae("/admin/datacenter-applications")},withdrawals:async()=>{a.value=await ae("/admin/withdrawals")},users:async()=>{r.value=await ae("/admin/users")}}[v];if(!g)return;const W=++te;m||(U.value=!0);try{await g()}finally{W===te&&(U.value=!1)}}async function _e(){await Ue(),R.value&&await Tt(R.value,!0)}async function ge(v){O.value="",k.value="";try{const m=await ae(`/admin/orders/${v.id}/approve`,{method:"POST"});k.value=`订单 #${v.id} 已开通,节点 ${m.display_node_id||m.sn}`,await _e()}catch(m){O.value=m.message}}async function ne(v){O.value="",k.value="";try{await ae(`/admin/orders/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"支付凭证未通过"})}),k.value=`订单 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}async function X(v){const m=(d.value[v.id]||"").trim();if(!m){O.value="请先填写链上哈希";return}O.value="",k.value="";try{await ae(`/admin/withdrawals/${v.id}/confirm`,{method:"POST",body:JSON.stringify({tx_hash:m})}),k.value=`提现 #${v.id} 已打款`,await _e()}catch(D){O.value=D.message}}async function Je(v){O.value="",k.value="";const m=!v.is_whitelist;try{await ae(`/admin/nodes/${encodeURIComponent(v.sn)}/whitelist`,{method:"POST",body:JSON.stringify({is_whitelist:m})}),k.value=m?`节点 ${v.display_node_id||v.sn} 已加入白名单`:`节点 ${v.display_node_id||v.sn} 已取消白名单`,await _e()}catch(D){O.value=D.message}}async function ze(v,m){O.value="",k.value="",F.value=null;try{await ae(`/admin/nodes/${encodeURIComponent(v.sn)}/ban`,{method:"POST",body:JSON.stringify({is_banned:m})}),k.value=m?`节点 ${v.display_node_id||v.sn} 已封禁`:`节点 ${v.display_node_id||v.sn} 已解除封禁`,await _e()}catch(D){O.value=D.message}}async function Ge(){O.value="",k.value="";let v;try{v=JSON.parse(E.value.payloadText)}catch{O.value="Payload 不是合法 JSON";return}if(!v||Array.isArray(v)||typeof v!="object"){O.value="Payload 需要是 JSON 对象";return}try{const m=await ae("/admin/tasks/create",{method:"POST",body:JSON.stringify({task_type:E.value.task_type,target_node_sn:E.value.target_node_sn||null,payload:v})});k.value=`已下发 ${m.length} 个任务`,await _e()}catch(m){O.value=m.message}}async function Se(){O.value="",k.value="";try{await ae("/admin/ip-whitelist",{method:"POST",body:JSON.stringify({ip_address:j.value.ip_address.trim(),note:j.value.note.trim()})}),j.value={ip_address:"",note:""},k.value="白名单 IP 已添加",await _e()}catch(v){O.value=v.message}}async function At(v){O.value="",k.value="";try{await ae(`/admin/datacenter-applications/${v.id}/approve`,{method:"POST"}),k.value=`机房申请 #${v.id} 已通过,网段 ${v.subnet_ip} 已加入白名单`,await _e()}catch(m){O.value=m.message}}async function Rt(v){O.value="",k.value="";try{await ae(`/admin/datacenter-applications/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"未达到机房门槛"})}),k.value=`机房申请 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}async function ht(v){O.value="",k.value="";try{await ae(`/admin/ip-whitelist/${v.id}`,{method:"DELETE"}),k.value=`${v.ip_address} 已移出白名单`,await _e()}catch(m){O.value=m.message}}async function Ve(v){O.value="",k.value="";try{await ae(`/admin/withdrawals/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"管理后台拒绝"})}),k.value=`提现 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}return en(R,async v=>{if(!v){s.replace("/admin/orders");return}try{await Tt(v)}catch(m){U.value=!1,O.value=m.message}},{immediate:!0}),Pn(async()=>{try{await Ue()}catch(v){O.value=v.message}w=window.setInterval(()=>{_e().catch(v=>{O.value=v.message})},1e4)}),In(()=>window.clearInterval(w)),(v,m)=>{const D=cs("router-link");return I(),qt(Ii,{eyebrow:"管理后台",title:"全网概览"},{default:Dt(()=>[O.value?(I(),N("p",Rf,C(O.value),1)):Y("",!0),k.value?(I(),N("p",Of,C(k.value),1)):Y("",!0),l.value?(I(),N("section",Pf,[o("article",If,[m[8]||(m[8]=o("span",null,"用户",-1)),o("strong",null,C(l.value.users),1),m[9]||(m[9]=o("em",null,"已注册钱包",-1))]),o("article",Nf,[m[10]||(m[10]=o("span",null,"全网节点",-1)),o("strong",null,C(l.value.nodes),1),o("em",null,"在线 "+C(l.value.online)+" · 离线 "+C(l.value.offline),1)]),o("article",kf,[m[11]||(m[11]=o("span",null,"总产出",-1)),o("strong",null,C(q(Ce)(l.value.settled_points)),1),o("em",null,"授权 "+C(q(Ce)(l.value.authorized_amount)),1)]),o("article",Df,[m[12]||(m[12]=o("span",null,"待审核",-1)),o("strong",null,C(q(Ce)(l.value.pending_withdrawals)),1),m[13]||(m[13]=o("em",null,"占用中的提现",-1))])])):Y("",!0),o("nav",$f,[(I(),N(ce,null,Ne(t,g=>be(D,{key:g.id,role:"tab",to:`/admin/${g.id}`,"aria-selected":R.value===g.id,class:he({on:R.value===g.id})},{default:Dt(()=>[ee(C(g.label),1)]),_:2},1032,["to","aria-selected","class"])),64))]),F.value?(I(),N("p",Mf,[ee(" 确认封禁节点 "+C(F.value.display_node_id||F.value.sn)+"?封禁后心跳会被拒绝。 ",1),o("button",{class:"danger",type:"button",onClick:m[0]||(m[0]=g=>ze(F.value,!0))},"确认封禁"),o("button",{class:"texty",type:"button",onClick:m[1]||(m[1]=g=>F.value=null)},"取消")])):Y("",!0),o("section",Vf,[U.value?(I(),N("p",Lf,"正在读取…")):R.value==="orders"?(I(),N("div",Uf,[o("table",null,[m[15]||(m[15]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"钱包"),o("th",null,"金额"),o("th",null,"TxID"),o("th",null,"状态"),o("th",null,"节点"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(u.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Ff,C(g.wallet_address),1),o("td",null,C(q(Ce)(g.amount))+" "+C(g.asset_symbol),1),o("td",jf,C(g.tx_id),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),o("td",null,C(g.display_node_id||g.sn||"—"),1),g.status==="pending"?(I(),N("td",Hf,[o("button",{class:"gold",type:"button",onClick:W=>ge(g)},"审核开通",8,Bf),o("button",{class:"texty",type:"button",onClick:W=>ne(g)},"拒绝",8,Kf)])):(I(),N("td",Gf))]))),128)),u.value.length?Y("",!0):(I(),N("tr",Wf,[...m[14]||(m[14]=[o("td",{colspan:"7",class:"empty"},"还没有授权订单。",-1)])]))])])])):R.value==="nodes"?(I(),N("section",qf,[(I(!0),N(ce,null,Ne(i.value,g=>(I(),N("article",{key:g.sn,class:"plate"},[o("header",null,[o("strong",null,C(g.display_node_id||g.sn),1),o("span",{class:he(["pill",g.is_banned?"rejected":g.status])},[m[16]||(m[16]=o("i",null,null,-1)),ee(C(g.is_banned?"已封禁":q(wt)[g.status]),1)],2)]),o("div",Jf,[g.is_whitelist?(I(),N("span",zf,"白名单")):Y("",!0),o("label",Yf,[o("input",{type:"checkbox",checked:g.is_whitelist,onChange:W=>Je(g)},null,40,Qf),m[17]||(m[17]=o("span",null,null,-1)),m[18]||(m[18]=ee(" 白名单 ",-1))]),g.is_banned?(I(),N("button",{key:1,class:"texty",type:"button",onClick:W=>ze(g,!1)},"解除封禁",8,Xf)):(I(),N("button",{key:2,class:"danger",type:"button",onClick:W=>F.value=g},"封禁",8,Zf))]),g.genesis?(I(),N("p",ed,"[Genesis Special Node - 30 Days Recovery]")):Y("",!0),g.genesis?(I(),N("p",td,"创世特权节点 - 30天回本")):Y("",!0),o("p",nd,C(g.wallet_address),1),o("p",sd,C(g.sn),1),o("p",ld,[o("b",{style:Rn({width:q(Xn)(g.online_rate)})},null,4),ee(C(q(Xn)(g.online_rate))+" 今日在线率",1)]),o("dl",null,[o("div",null,[m[19]||(m[19]=o("dt",null,"显卡",-1)),o("dd",null,C(pt(g)),1)]),o("div",null,[m[20]||(m[20]=o("dt",null,"CPU",-1)),o("dd",null,C(g.cpu_count?`${g.cpu_count} 核`:"—"),1)]),o("div",null,[m[21]||(m[21]=o("dt",null,"内存",-1)),o("dd",null,C(g.memory_gb?`${g.memory_gb} GB`:"—"),1)]),o("div",null,[m[22]||(m[22]=o("dt",null,"权重",-1)),o("dd",null,C(g.hardware_weight),1)]),o("div",null,[m[23]||(m[23]=o("dt",null,"世代",-1)),o("dd",null,C(Be(g)),1)]),o("div",null,[m[24]||(m[24]=o("dt",null,"绑定 IP",-1)),o("dd",null,C(g.client_ip||"—"),1)]),o("div",null,[m[25]||(m[25]=o("dt",null,"今日积分",-1)),o("dd",null,C(q(Ce)(g.today_points)),1)]),o("div",null,[m[26]||(m[26]=o("dt",null,"心跳",-1)),o("dd",null,C(q(Xt)(g.last_heartbeat_at)),1)])])]))),128)),i.value.length?Y("",!0):(I(),N("p",rd,"还没有节点。"))])):R.value==="tasks"?(I(),N("div",id,[o("form",{class:"panel",onSubmit:Gt(Ge,["prevent"])},[m[31]||(m[31]=o("h2",null,"发布任务",-1)),m[32]||(m[32]=o("p",{class:"sub"},"指定节点会进入该节点的领取队列。选择全部在线节点时,当前每个在线节点各收到一条。",-1)),o("div",od,[o("label",null,[m[27]||(m[27]=ee("任务类型 ",-1)),we(o("select",{"onUpdate:modelValue":m[2]||(m[2]=g=>E.value.task_type=g),onChange:tt},[(I(),N(ce,null,Ne(b,g=>o("option",{key:g.id,value:g.id},C(g.label),9,ad)),64))],544),[[Hs,E.value.task_type]])]),o("label",null,[m[29]||(m[29]=ee("目标节点 ",-1)),we(o("select",{"onUpdate:modelValue":m[3]||(m[3]=g=>E.value.target_node_sn=g)},[m[28]||(m[28]=o("option",{value:""},"全部在线节点",-1)),(I(!0),N(ce,null,Ne(i.value,g=>(I(),N("option",{key:g.sn,value:g.sn},C(g.display_node_id||g.sn)+" · "+C(g.sn),9,ud))),128))],512),[[Hs,E.value.target_node_sn]])]),o("label",cd,[m[30]||(m[30]=ee("Payload ",-1)),we(o("textarea",{"onUpdate:modelValue":m[4]||(m[4]=g=>E.value.payloadText=g),required:"",spellcheck:"false",onInput:m[5]||(m[5]=g=>P.value=!0)},null,544),[[De,E.value.payloadText]])])]),m[33]||(m[33]=o("button",{class:"gold",type:"submit"},"立即下发任务",-1))],32),o("div",fd,[o("table",null,[m[35]||(m[35]=o("thead",null,[o("tr",null,[o("th",null,"任务"),o("th",null,"类型"),o("th",null,"节点"),o("th",null,"状态"),o("th",null,"完成时间"),o("th",null,"结果")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(y.value,g=>(I(),N("tr",{key:g.task_id},[o("td",{class:"mono",title:g.task_id},C(g.task_id.slice(0,8)),9,dd),o("td",null,C(Ke(g.task_type)),1),o("td",null,C(g.display_node_id||"—")+" · "+C(g.target_node_sn),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(G[g.status]||g.status),3)]),o("td",null,C(q(Xt)(g.responded_at)),1),o("td",pd,C(g.result_preview||"—"),1)]))),128)),y.value.length?Y("",!0):(I(),N("tr",hd,[...m[34]||(m[34]=[o("td",{colspan:"6",class:"empty"},"还没有任务。",-1)])]))])])])])):R.value==="whitelist"?(I(),N("section",md,[m[41]||(m[41]=o("p",{class:"sub"},"可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。",-1)),o("form",{class:"ip-form",onSubmit:Gt(Se,["prevent"])},[o("label",null,[m[36]||(m[36]=ee("豁免 IP / 网段",-1)),we(o("input",{"onUpdate:modelValue":m[6]||(m[6]=g=>j.value.ip_address=g),required:"",placeholder:"203.0.113.0/24"},null,512),[[De,j.value.ip_address]])]),o("label",null,[m[37]||(m[37]=ee("备注",-1)),we(o("input",{"onUpdate:modelValue":m[7]||(m[7]=g=>j.value.note=g),placeholder:"自营机房出口"},null,512),[[De,j.value.note]])]),m[38]||(m[38]=o("button",{class:"gold",type:"submit"},"添加豁免 IP",-1))],32),o("div",gd,[o("table",null,[m[40]||(m[40]=o("thead",null,[o("tr",null,[o("th",null,"IP"),o("th",null,"备注"),o("th",null,"添加时间"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(c.value,g=>(I(),N("tr",{key:g.id},[o("td",vd,C(g.ip_address),1),o("td",null,C(g.note||"—"),1),o("td",null,C(q(Xt)(g.created_at)),1),o("td",_d,[o("button",{class:"texty",type:"button",onClick:W=>ht(g)},"移除",8,yd)])]))),128)),c.value.length?Y("",!0):(I(),N("tr",bd,[...m[39]||(m[39]=[o("td",{colspan:"4",class:"empty"},"还没有豁免 IP。",-1)])]))])])])])):R.value==="datacenter-apps"?(I(),N("div",wd,[o("table",null,[m[43]||(m[43]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"钱包"),o("th",null,"联系方式"),o("th",null,"节点数"),o("th",null,"网段"),o("th",null,"显卡"),o("th",null,"证明"),o("th",null,"状态"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(h.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Sd,C(g.wallet_address),1),o("td",null,C(g.contact),1),o("td",null,[ee(C(g.node_count)+" ",1),o("span",{class:he(["pill",g.node_count>=5?"pending":"rejected"])},C(g.node_count>=5?"≥ 5 台":"不足 5 台"),3)]),o("td",xd,C(g.subnet_ip),1),o("td",null,C(g.gpu_generation)+" 系",1),o("td",null,C(g.hardware_proof),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),g.status==="pending"?(I(),N("td",Ed,[o("button",{class:"gold",type:"button",onClick:W=>At(g)},"审批通过",8,Cd),o("button",{class:"texty",type:"button",onClick:W=>Rt(g)},"拒绝",8,Td)])):(I(),N("td",Ad))]))),128)),h.value.length?Y("",!0):(I(),N("tr",Rd,[...m[42]||(m[42]=[o("td",{colspan:"9",class:"empty"},"还没有机房申请。",-1)])]))])])])):R.value==="withdrawals"?(I(),N("div",Od,[m[46]||(m[46]=o("p",{class:"sub tab-note"},"折算金额按当前基准 1 积分 ≈ 0.95 USDT。实际打款以链上哈希为准。",-1)),o("div",Pd,[o("table",null,[m[45]||(m[45]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"USDT 地址"),o("th",null,"积分"),o("th",null,"折算金额"),o("th",null,"状态"),o("th",null,"链上哈希"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(a.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Id,C(g.wallet_address),1),o("td",null,C(q(Ce)(g.amount))+" "+C(g.asset_symbol),1),o("td",null,C(q(Ni)(g.amount))+" USDT",1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),o("td",null,[g.status==="pending"?we((I(),N("input",{key:0,"onUpdate:modelValue":W=>d.value[g.id]=W,placeholder:"交易哈希"},null,8,Nd)),[[De,d.value[g.id]]]):(I(),N("span",kd,C(g.tx_hash||"—"),1))]),g.status==="pending"?(I(),N("td",Dd,[o("button",{class:"gold",type:"button",onClick:W=>X(g)},"确认打款",8,$d),o("button",{class:"texty",type:"button",onClick:W=>Ve(g)},"拒绝",8,Md)])):(I(),N("td",Vd))]))),128)),a.value.length?Y("",!0):(I(),N("tr",Ld,[...m[44]||(m[44]=[o("td",{colspan:"7",class:"empty"},"还没有提现申请。",-1)])]))])])])])):R.value==="users"?(I(),N("div",Ud,[o("table",null,[m[48]||(m[48]=o("thead",null,[o("tr",null,[o("th",null,"钱包"),o("th",null,"用户名"),o("th",null,"角色"),o("th",null,"节点"),o("th",null,"可用积分"),o("th",null,"注册")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(r.value,g=>(I(),N("tr",{key:g.id},[o("td",Fd,C(g.wallet_address),1),o("td",null,C(g.username||"—"),1),o("td",null,C(q(wt)[g.role]),1),o("td",null,C(g.node_count),1),o("td",null,C(q(Ce)(g.available_points)),1),o("td",null,C(q(Xt)(g.created_at)),1)]))),128)),r.value.length?Y("",!0):(I(),N("tr",jd,[...m[47]||(m[47]=[o("td",{colspan:"6",class:"empty"},"还没有用户。",-1)])]))])])])):Y("",!0)])]),_:1})}}},ki=vc({history:zu(),routes:[{path:"/",redirect:()=>hl().role==="admin"?"/admin":"/dashboard"},{path:"/login",component:Dc},{path:"/dashboard",redirect:"/dashboard/nodes"},{path:"/dashboard/:tab",component:Af,meta:{auth:!0}},{path:"/admin",redirect:"/admin/orders"},{path:"/admin/:tab",component:Hd,meta:{auth:!0,admin:!0}}]});ki.beforeEach(e=>{const t=hl();return e.meta.auth&&!t.token?"/login":e.meta.admin&&t.role!=="admin"?"/dashboard":e.path==="/login"&&t.token?t.role==="admin"?"/admin":"/dashboard":!0});ru(fu).use(ki).mount("#app");
+}`}],E=Z({task_type:"alpha_mining",target_node_sn:"",payloadText:b[0].sample}),P=Z(!1),G={pending:"待领取",processing:"执行中",completed:"已完成",failed:"失败",expired:"已过期"},j=Z({ip_address:"",note:""}),O=Z(""),k=Z(""),U=Z(!1),F=Z(null);let w=0,te=0;const R=je(()=>{const v=String(n.params.tab||"");return t.some(m=>m.id===v)?v:""});function Be(v){return v.gpu_generation==="40"?"40 系":v.gpu_generation==="30"?"30 系":"10 系及以下"}function Ke(v){const m=b.find(D=>D.id===v);return m?m.label:v==="simulated_compute"?"模拟计算":v}function tt(){if(P.value)return;const v=b.find(m=>m.id===E.value.task_type);E.value.payloadText=v?v.sample:"{}"}function pt(v){return v.gpu_model?v.vram_gb?`${v.gpu_model} · ${v.vram_gb} GB`:v.gpu_model:"未上报"}async function Ue(){l.value=await ae("/admin/summary")}async function Tt(v,m=!1){const g={orders:async()=>{u.value=await ae("/admin/orders")},nodes:async()=>{i.value=await ae("/admin/nodes")},tasks:async()=>{const[f,p]=await Promise.all([ae("/admin/tasks/list"),ae("/admin/nodes")]);y.value=f,i.value=p},whitelist:async()=>{c.value=await ae("/admin/ip-whitelist")},"datacenter-apps":async()=>{h.value=await ae("/admin/datacenter-applications")},withdrawals:async()=>{a.value=await ae("/admin/withdrawals")},users:async()=>{r.value=await ae("/admin/users")}}[v];if(!g)return;const W=++te;m||(U.value=!0);try{await g()}finally{W===te&&(U.value=!1)}}async function _e(){await Ue(),R.value&&await Tt(R.value,!0)}async function ge(v){O.value="",k.value="";try{const m=await ae(`/admin/orders/${v.id}/approve`,{method:"POST"});k.value=`订单 #${v.id} 已开通,节点 ${m.display_node_id||m.sn}`,await _e()}catch(m){O.value=m.message}}async function ne(v){O.value="",k.value="";try{await ae(`/admin/orders/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"支付凭证未通过"})}),k.value=`订单 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}async function X(v){const m=(d.value[v.id]||"").trim();if(!m){O.value="请先填写链上哈希";return}O.value="",k.value="";try{await ae(`/admin/withdrawals/${v.id}/confirm`,{method:"POST",body:JSON.stringify({tx_hash:m})}),k.value=`提现 #${v.id} 已打款`,await _e()}catch(D){O.value=D.message}}async function Je(v){O.value="",k.value="";const m=!v.is_whitelist;try{await ae(`/admin/nodes/${encodeURIComponent(v.sn)}/whitelist`,{method:"POST",body:JSON.stringify({is_whitelist:m})}),k.value=m?`节点 ${v.display_node_id||v.sn} 已加入白名单`:`节点 ${v.display_node_id||v.sn} 已取消白名单`,await _e()}catch(D){O.value=D.message}}async function ze(v,m){O.value="",k.value="",F.value=null;try{await ae(`/admin/nodes/${encodeURIComponent(v.sn)}/ban`,{method:"POST",body:JSON.stringify({is_banned:m})}),k.value=m?`节点 ${v.display_node_id||v.sn} 已封禁`:`节点 ${v.display_node_id||v.sn} 已解除封禁`,await _e()}catch(D){O.value=D.message}}async function Ge(){O.value="",k.value="";let v;try{v=JSON.parse(E.value.payloadText)}catch{O.value="Payload 不是合法 JSON";return}if(!v||Array.isArray(v)||typeof v!="object"){O.value="Payload 需要是 JSON 对象";return}try{const m=await ae("/admin/tasks/create",{method:"POST",body:JSON.stringify({task_type:E.value.task_type,target_node_sn:E.value.target_node_sn||null,payload:v})});k.value=`已下发 ${m.length} 个任务`,await _e()}catch(m){O.value=m.message}}async function Se(){O.value="",k.value="";try{await ae("/admin/ip-whitelist",{method:"POST",body:JSON.stringify({ip_address:j.value.ip_address.trim(),note:j.value.note.trim()})}),j.value={ip_address:"",note:""},k.value="白名单 IP 已添加",await _e()}catch(v){O.value=v.message}}async function At(v){O.value="",k.value="";try{await ae(`/admin/datacenter-applications/${v.id}/approve`,{method:"POST"}),k.value=`机房申请 #${v.id} 已通过,网段 ${v.subnet_ip} 已加入白名单`,await _e()}catch(m){O.value=m.message}}async function Rt(v){O.value="",k.value="";try{await ae(`/admin/datacenter-applications/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"未达到机房门槛"})}),k.value=`机房申请 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}async function ht(v){O.value="",k.value="";try{await ae(`/admin/ip-whitelist/${v.id}`,{method:"DELETE"}),k.value=`${v.ip_address} 已移出白名单`,await _e()}catch(m){O.value=m.message}}async function Ve(v){O.value="",k.value="";try{await ae(`/admin/withdrawals/${v.id}/reject`,{method:"POST",body:JSON.stringify({note:"管理后台拒绝"})}),k.value=`提现 #${v.id} 已拒绝`,await _e()}catch(m){O.value=m.message}}return en(R,async v=>{if(!v){s.replace("/admin/orders");return}try{await Tt(v)}catch(m){U.value=!1,O.value=m.message}},{immediate:!0}),Pn(async()=>{try{await Ue()}catch(v){O.value=v.message}w=window.setInterval(()=>{_e().catch(v=>{O.value=v.message})},1e4)}),In(()=>window.clearInterval(w)),(v,m)=>{const D=cs("router-link");return I(),qt(Ii,{eyebrow:"管理后台",title:"全网概览"},{default:Dt(()=>[O.value?(I(),N("p",Rf,C(O.value),1)):Y("",!0),k.value?(I(),N("p",Of,C(k.value),1)):Y("",!0),l.value?(I(),N("section",Pf,[o("article",If,[m[8]||(m[8]=o("span",null,"用户",-1)),o("strong",null,C(l.value.users),1),m[9]||(m[9]=o("em",null,"已注册钱包",-1))]),o("article",Nf,[m[10]||(m[10]=o("span",null,"全网节点",-1)),o("strong",null,C(l.value.nodes),1),o("em",null,"在线 "+C(l.value.online)+" · 离线 "+C(l.value.offline),1)]),o("article",kf,[m[11]||(m[11]=o("span",null,"总产出",-1)),o("strong",null,C(q(Ce)(l.value.settled_points)),1),o("em",null,"授权 "+C(q(Ce)(l.value.authorized_amount)),1)]),o("article",Df,[m[12]||(m[12]=o("span",null,"待审核",-1)),o("strong",null,C(q(Ce)(l.value.pending_withdrawals)),1),m[13]||(m[13]=o("em",null,"占用中的提现",-1))])])):Y("",!0),o("nav",$f,[(I(),N(ce,null,Ne(t,g=>be(D,{key:g.id,role:"tab",to:`/admin/${g.id}`,"aria-selected":R.value===g.id,class:he({on:R.value===g.id})},{default:Dt(()=>[ee(C(g.label),1)]),_:2},1032,["to","aria-selected","class"])),64))]),F.value?(I(),N("p",Mf,[ee(" 确认封禁节点 "+C(F.value.display_node_id||F.value.sn)+"?封禁后心跳会被拒绝。 ",1),o("button",{class:"danger",type:"button",onClick:m[0]||(m[0]=g=>ze(F.value,!0))},"确认封禁"),o("button",{class:"texty",type:"button",onClick:m[1]||(m[1]=g=>F.value=null)},"取消")])):Y("",!0),o("section",Vf,[U.value?(I(),N("p",Lf,"正在读取…")):R.value==="orders"?(I(),N("div",Uf,[o("table",null,[m[15]||(m[15]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"钱包"),o("th",null,"金额"),o("th",null,"TxID"),o("th",null,"状态"),o("th",null,"节点"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(u.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Ff,C(g.wallet_address),1),o("td",null,C(q(Ce)(g.amount))+" "+C(g.asset_symbol),1),o("td",jf,C(g.tx_id),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),o("td",null,C(g.display_node_id||g.sn||"—"),1),g.status==="pending"?(I(),N("td",Hf,[o("button",{class:"gold",type:"button",onClick:W=>ge(g)},"审核开通",8,Bf),o("button",{class:"texty",type:"button",onClick:W=>ne(g)},"拒绝",8,Kf)])):(I(),N("td",Gf))]))),128)),u.value.length?Y("",!0):(I(),N("tr",Wf,[...m[14]||(m[14]=[o("td",{colspan:"7",class:"empty"},"还没有授权订单。",-1)])]))])])])):R.value==="nodes"?(I(),N("section",qf,[(I(!0),N(ce,null,Ne(i.value,g=>(I(),N("article",{key:g.sn,class:"plate"},[o("header",null,[o("strong",null,C(g.display_node_id||g.sn),1),o("span",{class:he(["pill",g.is_banned?"rejected":g.status])},[m[16]||(m[16]=o("i",null,null,-1)),ee(C(g.is_banned?"已封禁":q(wt)[g.status]),1)],2)]),o("div",Jf,[g.is_whitelist?(I(),N("span",zf,"白名单")):Y("",!0),o("label",Yf,[o("input",{type:"checkbox",checked:g.is_whitelist,onChange:W=>Je(g)},null,40,Qf),m[17]||(m[17]=o("span",null,null,-1)),m[18]||(m[18]=ee(" 白名单 ",-1))]),g.is_banned?(I(),N("button",{key:1,class:"texty",type:"button",onClick:W=>ze(g,!1)},"解除封禁",8,Xf)):(I(),N("button",{key:2,class:"danger",type:"button",onClick:W=>F.value=g},"封禁",8,Zf))]),g.genesis?(I(),N("p",ed,"[Genesis Special Node - 30 Days Recovery]")):Y("",!0),g.genesis?(I(),N("p",td,"创世特权节点 - 30天回本")):Y("",!0),o("p",nd,C(g.wallet_address),1),o("p",sd,C(g.sn),1),o("p",ld,[o("b",{style:Rn({width:q(Xn)(g.online_rate)})},null,4),ee(C(q(Xn)(g.online_rate))+" 今日在线率",1)]),o("dl",null,[o("div",null,[m[19]||(m[19]=o("dt",null,"显卡",-1)),o("dd",null,C(pt(g)),1)]),o("div",null,[m[20]||(m[20]=o("dt",null,"CPU",-1)),o("dd",null,C(g.cpu_count?`${g.cpu_count} 核`:"—"),1)]),o("div",null,[m[21]||(m[21]=o("dt",null,"内存",-1)),o("dd",null,C(g.memory_gb?`${g.memory_gb} GB`:"—"),1)]),o("div",null,[m[22]||(m[22]=o("dt",null,"权重",-1)),o("dd",null,C(g.hardware_weight),1)]),o("div",null,[m[23]||(m[23]=o("dt",null,"世代",-1)),o("dd",null,C(Be(g)),1)]),o("div",null,[m[24]||(m[24]=o("dt",null,"绑定 IP",-1)),o("dd",null,C(g.client_ip||"—"),1)]),o("div",null,[m[25]||(m[25]=o("dt",null,"今日积分",-1)),o("dd",null,C(q(Ce)(g.today_points)),1)]),o("div",null,[m[26]||(m[26]=o("dt",null,"心跳",-1)),o("dd",null,C(q(Xt)(g.last_heartbeat_at)),1)])])]))),128)),i.value.length?Y("",!0):(I(),N("p",rd,"还没有节点。"))])):R.value==="tasks"?(I(),N("div",id,[o("form",{class:"panel",onSubmit:Gt(Ge,["prevent"])},[m[31]||(m[31]=o("h2",null,"发布任务",-1)),m[32]||(m[32]=o("p",{class:"sub"},"指定节点会进入该节点的领取队列。选择全部在线节点时,当前每个在线节点各收到一条。",-1)),o("div",od,[o("label",null,[m[27]||(m[27]=ee("任务类型 ",-1)),we(o("select",{"onUpdate:modelValue":m[2]||(m[2]=g=>E.value.task_type=g),onChange:tt},[(I(),N(ce,null,Ne(b,g=>o("option",{key:g.id,value:g.id},C(g.label),9,ad)),64))],544),[[Hs,E.value.task_type]])]),o("label",null,[m[29]||(m[29]=ee("目标节点 ",-1)),we(o("select",{"onUpdate:modelValue":m[3]||(m[3]=g=>E.value.target_node_sn=g)},[m[28]||(m[28]=o("option",{value:""},"全部在线节点",-1)),(I(!0),N(ce,null,Ne(i.value,g=>(I(),N("option",{key:g.sn,value:g.sn},C(g.display_node_id||g.sn)+" · "+C(g.sn),9,ud))),128))],512),[[Hs,E.value.target_node_sn]])]),o("label",cd,[m[30]||(m[30]=ee("Payload ",-1)),we(o("textarea",{"onUpdate:modelValue":m[4]||(m[4]=g=>E.value.payloadText=g),required:"",spellcheck:"false",onInput:m[5]||(m[5]=g=>P.value=!0)},null,544),[[De,E.value.payloadText]])])]),m[33]||(m[33]=o("button",{class:"gold",type:"submit"},"立即下发任务",-1))],32),o("div",fd,[o("table",null,[m[35]||(m[35]=o("thead",null,[o("tr",null,[o("th",null,"任务"),o("th",null,"类型"),o("th",null,"节点"),o("th",null,"状态"),o("th",null,"完成时间"),o("th",null,"结果")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(y.value,g=>(I(),N("tr",{key:g.task_id},[o("td",{class:"mono",title:g.task_id},C(g.task_id.slice(0,8)),9,dd),o("td",null,C(Ke(g.task_type)),1),o("td",null,C(g.target_node_sn?`${g.display_node_id||"—"} · ${g.target_node_sn}`:"排队中"),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(G[g.status]||g.status),3)]),o("td",null,C(q(Xt)(g.responded_at)),1),o("td",pd,C(g.result_preview||"—"),1)]))),128)),y.value.length?Y("",!0):(I(),N("tr",hd,[...m[34]||(m[34]=[o("td",{colspan:"6",class:"empty"},"还没有任务。",-1)])]))])])])])):R.value==="whitelist"?(I(),N("section",md,[m[41]||(m[41]=o("p",{class:"sub"},"可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。",-1)),o("form",{class:"ip-form",onSubmit:Gt(Se,["prevent"])},[o("label",null,[m[36]||(m[36]=ee("豁免 IP / 网段",-1)),we(o("input",{"onUpdate:modelValue":m[6]||(m[6]=g=>j.value.ip_address=g),required:"",placeholder:"203.0.113.0/24"},null,512),[[De,j.value.ip_address]])]),o("label",null,[m[37]||(m[37]=ee("备注",-1)),we(o("input",{"onUpdate:modelValue":m[7]||(m[7]=g=>j.value.note=g),placeholder:"自营机房出口"},null,512),[[De,j.value.note]])]),m[38]||(m[38]=o("button",{class:"gold",type:"submit"},"添加豁免 IP",-1))],32),o("div",gd,[o("table",null,[m[40]||(m[40]=o("thead",null,[o("tr",null,[o("th",null,"IP"),o("th",null,"备注"),o("th",null,"添加时间"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(c.value,g=>(I(),N("tr",{key:g.id},[o("td",vd,C(g.ip_address),1),o("td",null,C(g.note||"—"),1),o("td",null,C(q(Xt)(g.created_at)),1),o("td",_d,[o("button",{class:"texty",type:"button",onClick:W=>ht(g)},"移除",8,yd)])]))),128)),c.value.length?Y("",!0):(I(),N("tr",bd,[...m[39]||(m[39]=[o("td",{colspan:"4",class:"empty"},"还没有豁免 IP。",-1)])]))])])])])):R.value==="datacenter-apps"?(I(),N("div",wd,[o("table",null,[m[43]||(m[43]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"钱包"),o("th",null,"联系方式"),o("th",null,"节点数"),o("th",null,"网段"),o("th",null,"显卡"),o("th",null,"证明"),o("th",null,"状态"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(h.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Sd,C(g.wallet_address),1),o("td",null,C(g.contact),1),o("td",null,[ee(C(g.node_count)+" ",1),o("span",{class:he(["pill",g.node_count>=5?"pending":"rejected"])},C(g.node_count>=5?"≥ 5 台":"不足 5 台"),3)]),o("td",xd,C(g.subnet_ip),1),o("td",null,C(g.gpu_generation)+" 系",1),o("td",null,C(g.hardware_proof),1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),g.status==="pending"?(I(),N("td",Ed,[o("button",{class:"gold",type:"button",onClick:W=>At(g)},"审批通过",8,Cd),o("button",{class:"texty",type:"button",onClick:W=>Rt(g)},"拒绝",8,Td)])):(I(),N("td",Ad))]))),128)),h.value.length?Y("",!0):(I(),N("tr",Rd,[...m[42]||(m[42]=[o("td",{colspan:"9",class:"empty"},"还没有机房申请。",-1)])]))])])])):R.value==="withdrawals"?(I(),N("div",Od,[m[46]||(m[46]=o("p",{class:"sub tab-note"},"折算金额按当前基准 1 积分 ≈ 0.95 USDT。实际打款以链上哈希为准。",-1)),o("div",Pd,[o("table",null,[m[45]||(m[45]=o("thead",null,[o("tr",null,[o("th",null,"单号"),o("th",null,"USDT 地址"),o("th",null,"积分"),o("th",null,"折算金额"),o("th",null,"状态"),o("th",null,"链上哈希"),o("th")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(a.value,g=>(I(),N("tr",{key:g.id},[o("td",null,"#"+C(g.id),1),o("td",Id,C(g.wallet_address),1),o("td",null,C(q(Ce)(g.amount))+" "+C(g.asset_symbol),1),o("td",null,C(q(Ni)(g.amount))+" USDT",1),o("td",null,[o("span",{class:he(["pill",g.status])},C(q(wt)[g.status]),3)]),o("td",null,[g.status==="pending"?we((I(),N("input",{key:0,"onUpdate:modelValue":W=>d.value[g.id]=W,placeholder:"交易哈希"},null,8,Nd)),[[De,d.value[g.id]]]):(I(),N("span",kd,C(g.tx_hash||"—"),1))]),g.status==="pending"?(I(),N("td",Dd,[o("button",{class:"gold",type:"button",onClick:W=>X(g)},"确认打款",8,$d),o("button",{class:"texty",type:"button",onClick:W=>Ve(g)},"拒绝",8,Md)])):(I(),N("td",Vd))]))),128)),a.value.length?Y("",!0):(I(),N("tr",Ld,[...m[44]||(m[44]=[o("td",{colspan:"7",class:"empty"},"还没有提现申请。",-1)])]))])])])])):R.value==="users"?(I(),N("div",Ud,[o("table",null,[m[48]||(m[48]=o("thead",null,[o("tr",null,[o("th",null,"钱包"),o("th",null,"用户名"),o("th",null,"角色"),o("th",null,"节点"),o("th",null,"可用积分"),o("th",null,"注册")])],-1)),o("tbody",null,[(I(!0),N(ce,null,Ne(r.value,g=>(I(),N("tr",{key:g.id},[o("td",Fd,C(g.wallet_address),1),o("td",null,C(g.username||"—"),1),o("td",null,C(q(wt)[g.role]),1),o("td",null,C(g.node_count),1),o("td",null,C(q(Ce)(g.available_points)),1),o("td",null,C(q(Xt)(g.created_at)),1)]))),128)),r.value.length?Y("",!0):(I(),N("tr",jd,[...m[47]||(m[47]=[o("td",{colspan:"6",class:"empty"},"还没有用户。",-1)])]))])])])):Y("",!0)])]),_:1})}}},ki=vc({history:zu(),routes:[{path:"/",redirect:()=>hl().role==="admin"?"/admin":"/dashboard"},{path:"/login",component:Dc},{path:"/dashboard",redirect:"/dashboard/nodes"},{path:"/dashboard/:tab",component:Af,meta:{auth:!0}},{path:"/admin",redirect:"/admin/orders"},{path:"/admin/:tab",component:Hd,meta:{auth:!0,admin:!0}}]});ki.beforeEach(e=>{const t=hl();return e.meta.auth&&!t.token?"/login":e.meta.admin&&t.role!=="admin"?"/dashboard":e.path==="/login"&&t.token?t.role==="admin"?"/admin":"/dashboard":!0});ru(fu).use(ki).mount("#app");
diff --git a/web/index.html b/web/index.html
index e59e280..d6eb21a 100644
--- a/web/index.html
+++ b/web/index.html
@@ -7,7 +7,7 @@
-
+