加上登录和角色权限,并用 Vue 控制台区分用户与管理员。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-10 22:43:31 +08:00
parent 78601aae5e
commit 8b55b9cf55
36 changed files with 2947 additions and 1007 deletions
+15
View File
@@ -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>
+1285
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+45
View File
@@ -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 "";
}
+79
View File
@@ -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>
+36
View File
@@ -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: "管理员",
};
+6
View File
@@ -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");
+23
View File
@@ -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;
});
+182
View File
@@ -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; }
}
+170
View File
@@ -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>
+133
View File
@@ -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>
+102
View File
@@ -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>
+16
View File
@@ -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",
},
},
});