加上登录和角色权限,并用 Vue 控制台区分用户与管理员。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user