订单需管理员审核后才开通节点,并更新登录页文案。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const statusText = {
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
pending: "待审核",
|
||||
approved: "已开通",
|
||||
completed: "已打款",
|
||||
rejected: "已拒绝",
|
||||
failed: "失败",
|
||||
|
||||
+36
-4
@@ -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%;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user