订单需管理员审核后才开通节点,并更新登录页文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-10 23:04:24 +08:00
parent 8b55b9cf55
commit f329f647c7
22 changed files with 583 additions and 81 deletions
+24
View File
@@ -37,6 +37,30 @@ export async function api(path, options = {}) {
return data;
}
export async function download(path, filename) {
const headers = {};
const token = localStorage.getItem("token");
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`/api/v1${path}`, { headers });
if (response.status === 401) {
clearSession();
if (location.pathname !== "/login") location.assign("/login");
throw new Error("登录已失效");
}
if (!response.ok) {
const text = await response.text();
const data = text ? JSON.parse(text) : null;
throw new Error(messageOf(data) || "下载失败");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function messageOf(data) {
if (!data) return "";
if (typeof data.detail === "string") return data.detail;
+1
View File
@@ -27,6 +27,7 @@ export const statusText = {
online: "在线",
offline: "离线",
pending: "待审核",
approved: "已开通",
completed: "已打款",
rejected: "已拒绝",
failed: "失败",
+36 -4
View File
@@ -49,13 +49,43 @@ a { color: inherit; }
}
.gate-copy h1 {
margin: 12px 0 18px;
max-width: 11em;
font-family: var(--serif);
font-size: clamp(52px, 6vw, 88px);
font-size: clamp(40px, 4vw, 64px);
font-weight: 560;
letter-spacing: -0.04em;
line-height: 0.95;
line-height: 1.08;
}
.gate-copy p:last-child { max-width: 420px; color: var(--muted); font-size: 18px; line-height: 1.6; }
.gate-copy p:last-child { max-width: 460px; color: var(--muted); font-size: 18px; line-height: 1.6; }
.agreement-link {
border: 0;
background: none;
padding: 0;
color: var(--gold);
text-decoration: underline;
text-underline-offset: 3px;
}
.agreement-mask {
position: fixed;
inset: 0;
z-index: 6;
display: grid;
place-items: center;
padding: 28px;
background: rgba(5, 6, 8, 0.72);
}
.agreement-sheet {
width: min(720px, 100%);
max-height: min(82vh, 860px);
overflow: auto;
}
.agreement-sheet h3 {
margin-top: 18px;
font-family: var(--serif);
font-size: 20px;
font-weight: 560;
}
.agreement-sheet p { line-height: 1.7; }
.eyebrow {
color: var(--gold);
letter-spacing: 0.18em;
@@ -87,7 +117,9 @@ a { color: inherit; }
font-weight: 700;
}
.gold.wide { width: 100%; margin-top: 4px; }
.gold:disabled { opacity: 0.6; }
.gold:disabled, .texty:disabled { opacity: 0.45; cursor: not-allowed; }
.token-box { font-family: var(--mono); font-size: 12px; }
.plate.locked { border-style: dashed; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
input {
width: 100%;
+56 -1
View File
@@ -8,22 +8,52 @@ const summary = ref(null);
const users = ref([]);
const nodes = ref([]);
const withdrawals = ref([]);
const orders = ref([]);
const hashes = ref({});
const error = ref("");
const notice = ref("");
let timer = 0;
async function load() {
const [nextSummary, nextUsers, nextNodes, nextCash] = await Promise.all([
const [nextSummary, nextUsers, nextNodes, nextCash, nextOrders] = await Promise.all([
api("/admin/summary"),
api("/admin/users"),
api("/admin/nodes"),
api("/admin/withdrawals"),
api("/admin/orders"),
]);
summary.value = nextSummary;
users.value = nextUsers;
nodes.value = nextNodes;
withdrawals.value = nextCash;
orders.value = nextOrders;
}
async function approveOrder(row) {
error.value = "";
notice.value = "";
try {
const result = await api(`/admin/orders/${row.id}/approve`, { method: "POST" });
notice.value = `订单 #${row.id} 已开通,节点 ${result.sn}`;
await load();
} catch (err) {
error.value = err.message;
}
}
async function rejectOrder(row) {
error.value = "";
notice.value = "";
try {
await api(`/admin/orders/${row.id}/reject`, {
method: "POST",
body: JSON.stringify({ note: "支付凭证未通过" }),
});
notice.value = `订单 #${row.id} 已拒绝`;
await load();
} catch (err) {
error.value = err.message;
}
}
async function confirm(row) {
@@ -104,6 +134,31 @@ onUnmounted(() => window.clearInterval(timer));
</article>
</section>
<h2 class="section-title">授权订单</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>钱包</th><th>金额</th><th>TxID</th><th>状态</th><th>节点</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in orders" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.wallet_address }}</td>
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
<td class="mono">{{ row.tx_id }}</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td>{{ row.sn || "—" }}</td>
<td class="actions" v-if="row.status === 'pending'">
<button class="gold" type="button" @click="approveOrder(row)">通过</button>
<button class="texty" type="button" @click="rejectOrder(row)">拒绝</button>
</td>
<td v-else></td>
</tr>
<tr v-if="!orders.length"><td colspan="7" class="empty">还没有授权订单。</td></tr>
</tbody>
</table>
</div>
<h2 class="section-title">节点监控</h2>
<section class="board">
<article v-for="node in nodes" :key="node.sn" class="plate">
+45 -20
View File
@@ -1,13 +1,13 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import Shell from "../components/Shell.vue";
import { api } from "../api";
import { api, download } 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 txId = ref("");
const cashForm = ref({ amount: "", asset_symbol: "POINT" });
let timer = 0;
@@ -27,16 +27,25 @@ async function run(work, done) {
}
}
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();
function placeOrder() {
return run(async () => {
await api("/me/nodes", { method: "POST", body: JSON.stringify(body) });
nodeForm.value = { sn: "", purchase_amount: "", callback_url: "" };
}, "节点已登记");
await api("/me/orders", { method: "POST", body: JSON.stringify({ tx_id: txId.value.trim() }) });
txId.value = "";
}, "支付凭证已提交,等待审核");
}
async function copyToken(token) {
error.value = "";
try {
await navigator.clipboard.writeText(token);
notice.value = "Token 已复制";
} catch {
error.value = "复制失败,请手动选择 Token";
}
}
function downloadClient(node) {
return run(() => download(`/me/nodes/${encodeURIComponent(node.sn)}/client`, `hypernode-${node.sn}.py`), "客户端已开始下载");
}
function requestCash() {
@@ -91,10 +100,23 @@ onUnmounted(() => window.clearInterval(timer));
</section>
<section class="board">
<article v-for="order in board?.orders || []" :key="order.id" class="plate" :class="{ locked: order.status !== 'approved' }">
<header>
<strong>订单 #{{ order.id }}</strong>
<span class="pill" :class="order.status"><i />{{ statusText[order.status] }}</span>
</header>
<p class="wallet">{{ money(order.amount) }} {{ order.asset_symbol }} · {{ order.tx_id }}</p>
<p v-if="order.status === 'pending'" class="sub">审核通过前不能复制 Token,也不能下载客户端。</p>
<p v-else-if="order.sn" class="sub">节点 {{ order.sn }}</p>
<div v-if="order.status !== 'approved'" class="actions">
<button class="gold" type="button" disabled>复制 Token</button>
<button class="texty" type="button" disabled>下载客户端</button>
</div>
</article>
<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>
<span class="pill" :class="node.activated ? node.status : 'pending'"><i />{{ node.activated ? statusText[node.status] : "待审核" }}</span>
</header>
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线</p>
<dl>
@@ -102,18 +124,21 @@ onUnmounted(() => window.clearInterval(timer));
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
<div><dt>最近心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
</dl>
<label>挂机 Token<input class="token-box" :value="node.activated ? node.node_token : '审核通过后发放'" readonly /></label>
<div class="actions">
<button class="gold" type="button" :disabled="!node.activated" @click="copyToken(node.node_token)">复制 Token</button>
<button class="texty" type="button" :disabled="!node.activated" @click="downloadClient(node)">下载客户端</button>
</div>
</article>
<p v-if="board && !board.nodes.length" class="empty">还没有节点。下面可以登记第一台。</p>
<p v-if="board && !board.nodes.length && !board.orders.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 class="panel" @submit.prevent="placeOrder">
<h2>购买节点授权</h2>
<p class="sub">{{ board ? money(board.license_price) : "1,500" }} {{ board?.license_asset || "USDT" }}。提交链上 TxID 后进入待审核,通过后开通 12 个月并签发 Token。</p>
<label>TxID<input v-model="txId" required minlength="6" placeholder="链上交易哈希" /></label>
<button class="gold" type="submit">提交支付凭证</button>
</form>
<form class="panel" @submit.prevent="requestCash">
<h2>申请提现</h2>
+39 -4
View File
@@ -14,6 +14,7 @@ const form = ref({
password: "",
confirm: "",
});
const termsOpen = ref(false);
async function submit() {
error.value = "";
@@ -65,8 +66,8 @@ function register() {
<div class="gate">
<section class="gate-copy">
<p class="eyebrow">HyperNode</p>
<h1>算力在这里入账。</h1>
<p>钱包或账号进入自己的节点。管理员从这里审核提现、看全网在线。</p>
<h1>连接全球 DePIN 分布式算力网络</h1>
<p>基于去中心化节点的推理验证与任务调度平台。连接您的算力节点,实时监控质押释放与算力收益。</p>
</section>
<form class="gate-card" @submit.prevent="submit">
<div class="switch">
@@ -80,7 +81,7 @@ function register() {
<label v-if="panel === 'register' || method === 'wallet'">
钱包地址
<input v-model="form.wallet_address" required minlength="4" placeholder="0xabc123" autocomplete="username" />
<input v-model="form.wallet_address" required minlength="4" placeholder="请填写您的 USDT (TRC20/ERC20) 收益接收地址" autocomplete="username" />
</label>
<label v-if="panel === 'register' || method === 'account'">
用户名
@@ -96,7 +97,41 @@ function register() {
</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>
<p class="hint">
注册即代表您已阅读并同意
<button type="button" class="agreement-link" @click="termsOpen = true">《算力节点质押与服务协议》</button>
</p>
</form>
<div v-if="termsOpen" class="agreement-mask" @click.self="termsOpen = false">
<article class="panel agreement-sheet">
<p class="eyebrow">HyperNode</p>
<h2>算力节点质押与服务协议</h2>
<p class="sub">生效日期:2026年10月 · 协议版本:v1.2</p>
<p>欢迎您申请参与 HyperNode 去中心化 AI 算力网络(以下简称“本网络”或“平台”)。在您勾选“同意协议”或完成 USDT 质押激活节点之前,请务必仔细阅读本协议的所有条款。</p>
<h3>一、定义与说明</h3>
<p>算力节点(Compute Node):指由用户运行或绑定的、通过轻量级客户端接入 HyperNode 网络的边缘计算节点。</p>
<p>节点履约质押金(Collateral Stake):指用户为保障节点运行质量(SLA)、防止网络作弊或恶意掉线,向平台系统质押的 1500 USDT 保证金。</p>
<p>节点 Token(Node Token):平台下发给已激活节点的专属加密凭证,用于客户端与网络服务端的鉴权与通信。</p>
<p>算力积分与收益(Yield / Points):指节点通过维持在线、响应网络数据推理验证与任务调度所获得的贡献奖励。</p>
<h3>二、质押与激活规则</h3>
<p>门槛与额度:激活单个标准 AI 算力节点需提交 1500 USDT 作为履约质押金。</p>
<p>审核与开通:用户提交链上交易哈希(TxID)后,需经系统及管理员清算审核。审核通过后,节点正式进入 12 个月的授权运行周期,并解锁客户端下载及 Node Token 绑定权限。</p>
<p>质押金优先抵扣与赎回:节点每日产生的算力收益,将优先计入“质押金赎回额度”。当累计释放收益达到 1500 USDT(即本金全额解锁)后,后续产生的收益自动转为“网络算力激励”。</p>
<h3>三、节点运行规范与 SLA 服务质量考核</h3>
<p>在线率要求:节点需保持每天不少于规定时长的在线状态,并正常响应服务端的随机任务下发与心跳检测。</p>
<p>收益衰减与扣罚机制:每日算力收益由“网络在线率”与“任务响应率”共同决定。若节点频繁离线、断网或拒绝响应计算任务,当天的表现系数将自动降低,导致当日算力收益缩减或清零,质押金赎回周期将相应顺延。</p>
<p>全网算力难度调整:平台根据全网总激活节点数量与每日总奖池,动态调整分配系数。节点收益受全网总算力竞争影响。</p>
<h3>四、提现与清算机制</h3>
<p>提现申请:用户可在控制台申请将已解冻的积分/USDT 提现至指定的个人加密钱包地址(TRC20 / ERC20)。</p>
<p>人工审核与安全排查:为防范 Sybil 攻击(女巫攻击)与自动化作弊,所有提现申请需经过平台安全清算与管理员审核。平台有权在发现异常流量或欺诈挂机时冻结相关节点。</p>
<h3>五、免责声明与风险提示</h3>
<p>网络与硬件风险:用户需自行承担因个人宽带中断、设备损坏、电力故障或客户端被防火墙拦截导致的节点掉线损失。</p>
<p>区块链与资产风险:请务必确认填写的 USDT 接收地址准确无误。因用户个人地址填写错误导致的资产损失,平台概不负责。</p>
<p>协议修改:平台保留根据网络发展与技术升级对本协议条款、产出算法进行微调的权利,更新后的条款将于控制台公示后生效。</p>
<p>点击“同意”或继续提交质押申请,即代表您已完全理解并接受上述所有条款与风险提示。</p>
<button class="gold" type="button" @click="termsOpen = false">同意</button>
</article>
</div>
</div>
</template>