控制台改为选项卡,并支持封禁节点与下发计算任务。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { clearSession, session } from "../api";
|
||||
|
||||
defineProps({
|
||||
@@ -9,6 +9,7 @@ defineProps({
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const current = session();
|
||||
const clock = ref("--:--:--");
|
||||
let timer = 0;
|
||||
@@ -52,8 +53,8 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
</span>
|
||||
</router-link>
|
||||
<nav class="nav">
|
||||
<router-link to="/dashboard">我的节点</router-link>
|
||||
<router-link v-if="current.role === 'admin'" to="/admin">管理后台</router-link>
|
||||
<router-link to="/dashboard/nodes" :class="{ 'router-link-active': route.path.startsWith('/dashboard') }">我的节点</router-link>
|
||||
<router-link v-if="current.role === 'admin'" to="/admin/orders" :class="{ 'router-link-active': route.path.startsWith('/admin') }">管理后台</router-link>
|
||||
</nav>
|
||||
<div class="rail-foot">
|
||||
<span class="who">{{ current.username || current.wallet }}</span>
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
const POINT_USDT_RATE = 0.95;
|
||||
|
||||
export function usdtFromPoints(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return "—";
|
||||
return money(number * POINT_USDT_RATE);
|
||||
}
|
||||
|
||||
export function money(value) {
|
||||
const [whole, fraction = ""] = String(value ?? "0").split(".");
|
||||
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
|
||||
@@ -9,8 +9,10 @@ export const router = createRouter({
|
||||
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 } },
|
||||
{ path: "/dashboard", redirect: "/dashboard/nodes" },
|
||||
{ path: "/dashboard/:tab", component: Dashboard, meta: { auth: true } },
|
||||
{ path: "/admin", redirect: "/admin/orders" },
|
||||
{ path: "/admin/:tab", component: AdminOverview, meta: { auth: true, admin: true } },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
+41
-5
@@ -185,14 +185,15 @@ input:focus { border-color: var(--gold); }
|
||||
.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; }
|
||||
.plate dl div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; font-size: 14px; }
|
||||
.plate dt { flex: none; color: var(--muted); white-space: nowrap; }
|
||||
.plate dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
||||
.admin-board { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
|
||||
.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); }
|
||||
.pill.pending, .pill.processing { color: var(--gold); }
|
||||
.genesis-tag {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
@@ -214,7 +215,42 @@ input:focus { border-color: var(--gold); }
|
||||
.toggle input:checked + span { border-color: #e0c07a; }
|
||||
.toggle input:checked + span::after { transform: translateX(18px); background: #e0c07a; }
|
||||
.toggle input:focus-visible + span { outline: 1px solid #e0c07a; }
|
||||
.whitelist-panel { margin-top: 8px; }
|
||||
.whitelist-panel { margin-top: 0; }
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 28px 0 18px;
|
||||
padding: 6px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(8, 9, 13, 0.62);
|
||||
}
|
||||
.admin-tabs a {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.admin-tabs a.on {
|
||||
color: var(--gold);
|
||||
background: rgba(224, 192, 122, 0.1);
|
||||
border-color: #e0c07a;
|
||||
box-shadow: 0 0 0 1px rgba(224, 192, 122, 0.28), 0 0 22px rgba(224, 192, 122, 0.24);
|
||||
}
|
||||
.tab-panel { min-height: 220px; }
|
||||
.tab-note { margin-bottom: 12px; }
|
||||
.confirm-ban { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; color: #e7b2a6; }
|
||||
.danger {
|
||||
border: 1px solid rgba(208, 138, 120, 0.75);
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(208, 138, 120, 0.08);
|
||||
color: #e7b2a6;
|
||||
}
|
||||
.ip-form { display: grid; grid-template-columns: 1.2fr 1fr auto; gap: 12px; align-items: end; }
|
||||
.ip-form button { height: 42px; }
|
||||
@media (max-width: 720px) { .ip-form { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import Shell from "../components/Shell.vue";
|
||||
import { api } from "../api";
|
||||
import { money, pct, statusText, when } from "../format";
|
||||
import { money, pct, statusText, usdtFromPoints, when } from "../format";
|
||||
|
||||
const tabs = [
|
||||
{ id: "orders", label: "授权订单" },
|
||||
{ id: "nodes", label: "节点监控" },
|
||||
{ id: "tasks", label: "任务调度" },
|
||||
{ id: "whitelist", label: "IP 白名单" },
|
||||
{ id: "datacenter-apps", label: "机房大客户申请" },
|
||||
{ id: "withdrawals", label: "提现审核" },
|
||||
{ id: "users", label: "用户" },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const summary = ref(null);
|
||||
const users = ref([]);
|
||||
const nodes = ref([]);
|
||||
@@ -12,28 +25,106 @@ const orders = ref([]);
|
||||
const hashes = ref({});
|
||||
const whitelist = ref([]);
|
||||
const applications = ref([]);
|
||||
const dispatched = ref([]);
|
||||
const taskTypes = [
|
||||
{ id: "alpha_mining", label: "量化因子挖掘", sample: '{\n "operators": ["ts_mean", "rank"],\n "population": 64\n}' },
|
||||
{ id: "llm_inference", label: "大模型推理", sample: '{\n "model": "qwen2.5-7b",\n "prompt": "summarize the brief",\n "max_tokens": 256\n}' },
|
||||
{ id: "lora_training", label: "QLoRA 微调", sample: '{\n "base_model": "qwen2.5-7b",\n "rank": 16,\n "steps": 100\n}' },
|
||||
{ id: "stress_test", label: "简易压测", sample: '{\n "duration_seconds": 60,\n "matrix": 4096\n}' },
|
||||
];
|
||||
const taskForm = ref({
|
||||
task_type: "alpha_mining",
|
||||
target_node_sn: "",
|
||||
payloadText: taskTypes[0].sample,
|
||||
});
|
||||
const payloadDirty = ref(false);
|
||||
const taskStatusText = {
|
||||
pending: "待领取",
|
||||
processing: "执行中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
expired: "已过期",
|
||||
};
|
||||
const ipForm = ref({ ip_address: "", note: "" });
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const tabLoading = ref(false);
|
||||
const pendingBan = ref(null);
|
||||
let timer = 0;
|
||||
let requestSerial = 0;
|
||||
|
||||
async function load() {
|
||||
const [nextSummary, nextUsers, nextNodes, nextCash, nextOrders, nextIps, nextApps] = await Promise.all([
|
||||
api("/admin/summary"),
|
||||
api("/admin/users"),
|
||||
api("/admin/nodes"),
|
||||
api("/admin/withdrawals"),
|
||||
api("/admin/orders"),
|
||||
api("/admin/ip-whitelist"),
|
||||
api("/admin/datacenter-applications"),
|
||||
]);
|
||||
summary.value = nextSummary;
|
||||
users.value = nextUsers;
|
||||
nodes.value = nextNodes;
|
||||
withdrawals.value = nextCash;
|
||||
orders.value = nextOrders;
|
||||
whitelist.value = nextIps;
|
||||
applications.value = nextApps;
|
||||
const activeTab = computed(() => {
|
||||
const id = String(route.params.tab || "");
|
||||
return tabs.some((tab) => tab.id === id) ? id : "";
|
||||
});
|
||||
|
||||
function generationText(node) {
|
||||
if (node.gpu_generation === "40") return "40 系";
|
||||
if (node.gpu_generation === "30") return "30 系";
|
||||
return "10 系及以下";
|
||||
}
|
||||
|
||||
function taskTypeText(value) {
|
||||
const known = taskTypes.find((item) => item.id === value);
|
||||
if (known) return known.label;
|
||||
return value === "simulated_compute" ? "模拟计算" : value;
|
||||
}
|
||||
|
||||
function fillTaskSample() {
|
||||
if (payloadDirty.value) return;
|
||||
const sample = taskTypes.find((item) => item.id === taskForm.value.task_type);
|
||||
taskForm.value.payloadText = sample ? sample.sample : "{}";
|
||||
}
|
||||
|
||||
function gpuText(node) {
|
||||
if (!node.gpu_model) return "未上报";
|
||||
return node.vram_gb ? `${node.gpu_model} · ${node.vram_gb} GB` : node.gpu_model;
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
summary.value = await api("/admin/summary");
|
||||
}
|
||||
|
||||
async function loadTab(id, quiet = false) {
|
||||
const loaders = {
|
||||
orders: async () => {
|
||||
orders.value = await api("/admin/orders");
|
||||
},
|
||||
nodes: async () => {
|
||||
nodes.value = await api("/admin/nodes");
|
||||
},
|
||||
tasks: async () => {
|
||||
const [nextTasks, nextNodes] = await Promise.all([api("/admin/tasks/list"), api("/admin/nodes")]);
|
||||
dispatched.value = nextTasks;
|
||||
nodes.value = nextNodes;
|
||||
},
|
||||
whitelist: async () => {
|
||||
whitelist.value = await api("/admin/ip-whitelist");
|
||||
},
|
||||
"datacenter-apps": async () => {
|
||||
applications.value = await api("/admin/datacenter-applications");
|
||||
},
|
||||
withdrawals: async () => {
|
||||
withdrawals.value = await api("/admin/withdrawals");
|
||||
},
|
||||
users: async () => {
|
||||
users.value = await api("/admin/users");
|
||||
},
|
||||
};
|
||||
const load = loaders[id];
|
||||
if (!load) return;
|
||||
const serial = ++requestSerial;
|
||||
if (!quiet) tabLoading.value = true;
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
if (serial === requestSerial) tabLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await loadSummary();
|
||||
if (activeTab.value) await loadTab(activeTab.value, true);
|
||||
}
|
||||
|
||||
async function approveOrder(row) {
|
||||
@@ -42,7 +133,7 @@ async function approveOrder(row) {
|
||||
try {
|
||||
const result = await api(`/admin/orders/${row.id}/approve`, { method: "POST" });
|
||||
notice.value = `订单 #${row.id} 已开通,节点 ${result.display_node_id || result.sn}`;
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
@@ -57,7 +148,7 @@ async function rejectOrder(row) {
|
||||
body: JSON.stringify({ note: "支付凭证未通过" }),
|
||||
});
|
||||
notice.value = `订单 #${row.id} 已拒绝`;
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
@@ -77,7 +168,7 @@ async function confirm(row) {
|
||||
body: JSON.stringify({ tx_hash: txHash }),
|
||||
});
|
||||
notice.value = `提现 #${row.id} 已打款`;
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
@@ -95,7 +186,55 @@ async function toggleWhitelist(node) {
|
||||
notice.value = next
|
||||
? `节点 ${node.display_node_id || node.sn} 已加入白名单`
|
||||
: `节点 ${node.display_node_id || node.sn} 已取消白名单`;
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function setBan(node, banned) {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
pendingBan.value = null;
|
||||
try {
|
||||
await api(`/admin/nodes/${encodeURIComponent(node.sn)}/ban`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ is_banned: banned }),
|
||||
});
|
||||
notice.value = banned
|
||||
? `节点 ${node.display_node_id || node.sn} 已封禁`
|
||||
: `节点 ${node.display_node_id || node.sn} 已解除封禁`;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(taskForm.value.payloadText);
|
||||
} catch {
|
||||
error.value = "Payload 不是合法 JSON";
|
||||
return;
|
||||
}
|
||||
if (!payload || Array.isArray(payload) || typeof payload !== "object") {
|
||||
error.value = "Payload 需要是 JSON 对象";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await api("/admin/tasks/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
task_type: taskForm.value.task_type,
|
||||
target_node_sn: taskForm.value.target_node_sn || null,
|
||||
payload,
|
||||
}),
|
||||
});
|
||||
notice.value = `已下发 ${created.length} 个任务`;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
@@ -114,7 +253,7 @@ async function addIp() {
|
||||
});
|
||||
ipForm.value = { ip_address: "", note: "" };
|
||||
notice.value = "白名单 IP 已添加";
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
@@ -126,7 +265,7 @@ async function approveDatacenter(row) {
|
||||
try {
|
||||
await api(`/admin/datacenter-applications/${row.id}/approve`, { method: "POST" });
|
||||
notice.value = `机房申请 #${row.id} 已通过,网段 ${row.subnet_ip} 已加入白名单`;
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
@@ -141,7 +280,7 @@ async function rejectDatacenter(row) {
|
||||
body: JSON.stringify({ note: "未达到机房门槛" }),
|
||||
});
|
||||
notice.value = `机房申请 #${row.id} 已拒绝`;
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
@@ -153,7 +292,7 @@ async function removeIp(row) {
|
||||
try {
|
||||
await api(`/admin/ip-whitelist/${row.id}`, { method: "DELETE" });
|
||||
notice.value = `${row.ip_address} 已移出白名单`;
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
@@ -168,20 +307,37 @@ async function reject(row) {
|
||||
body: JSON.stringify({ note: "管理后台拒绝" }),
|
||||
});
|
||||
notice.value = `提现 #${row.id} 已拒绝`;
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
activeTab,
|
||||
async (id) => {
|
||||
if (!id) {
|
||||
router.replace("/admin/orders");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadTab(id);
|
||||
} catch (err) {
|
||||
tabLoading.value = false;
|
||||
error.value = err.message;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await load();
|
||||
await loadSummary();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
timer = window.setInterval(() => {
|
||||
load().catch((err) => {
|
||||
refresh().catch((err) => {
|
||||
error.value = err.message;
|
||||
});
|
||||
}, 10000);
|
||||
@@ -217,157 +373,229 @@ 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.display_node_id || 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>
|
||||
<nav class="admin-tabs" role="tablist" aria-label="管理分类">
|
||||
<router-link
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
role="tab"
|
||||
:to="`/admin/${tab.id}`"
|
||||
:aria-selected="activeTab === tab.id"
|
||||
:class="{ on: activeTab === tab.id }"
|
||||
>{{ tab.label }}</router-link>
|
||||
</nav>
|
||||
|
||||
<h2 class="section-title">节点监控</h2>
|
||||
<section class="board">
|
||||
<article v-for="node in nodes" :key="node.sn" class="plate">
|
||||
<header>
|
||||
<strong>{{ node.display_node_id || node.sn }}</strong>
|
||||
<span class="pill" :class="node.status"><i />{{ statusText[node.status] }}</span>
|
||||
</header>
|
||||
<div class="whitelist-row">
|
||||
<span v-if="node.is_whitelist" class="pill pending">白名单</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" :checked="node.is_whitelist" @change="toggleWhitelist(node)" />
|
||||
<span></span>
|
||||
白名单
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
|
||||
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
|
||||
<p class="wallet">{{ node.wallet_address }}</p>
|
||||
<p class="sub">{{ node.sn }}</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>
|
||||
<div><dt>绑定 IP</dt><dd>{{ node.client_ip || "—" }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<p v-if="!nodes.length" class="empty">还没有节点。</p>
|
||||
</section>
|
||||
<p v-if="pendingBan" class="banner confirm-ban">
|
||||
确认封禁节点 {{ pendingBan.display_node_id || pendingBan.sn }}?封禁后心跳会被拒绝。
|
||||
<button class="danger" type="button" @click="setBan(pendingBan, true)">确认封禁</button>
|
||||
<button class="texty" type="button" @click="pendingBan = null">取消</button>
|
||||
</p>
|
||||
|
||||
<h2 class="section-title">IP 白名单设置</h2>
|
||||
<section class="panel whitelist-panel">
|
||||
<p class="sub">可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。</p>
|
||||
<form class="ip-form" @submit.prevent="addIp">
|
||||
<label>豁免 IP / 网段<input v-model="ipForm.ip_address" required placeholder="203.0.113.0/24" /></label>
|
||||
<label>备注<input v-model="ipForm.note" placeholder="自营机房出口" /></label>
|
||||
<button class="gold" type="submit">添加</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<section class="tab-panel" role="tabpanel">
|
||||
<p v-if="tabLoading" class="empty">正在读取…</p>
|
||||
|
||||
<div v-else-if="activeTab === 'orders'" class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>IP</th><th>备注</th><th>添加时间</th><th></th></tr>
|
||||
<tr><th>单号</th><th>钱包</th><th>金额</th><th>TxID</th><th>状态</th><th>节点</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in whitelist" :key="row.id">
|
||||
<td class="mono">{{ row.ip_address }}</td>
|
||||
<td>{{ row.note || "—" }}</td>
|
||||
<td>{{ when(row.created_at) }}</td>
|
||||
<td class="actions"><button class="texty" type="button" @click="removeIp(row)">删除</button></td>
|
||||
<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.display_node_id || 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="!whitelist.length"><td colspan="4" class="empty">还没有豁免 IP。</td></tr>
|
||||
<tr v-if="!orders.length"><td colspan="7" class="empty">还没有授权订单。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section v-else-if="activeTab === 'nodes'" class="board admin-board">
|
||||
<article v-for="node in nodes" :key="node.sn" class="plate">
|
||||
<header>
|
||||
<strong>{{ node.display_node_id || node.sn }}</strong>
|
||||
<span class="pill" :class="node.is_banned ? 'rejected' : node.status"><i />{{ node.is_banned ? "已封禁" : statusText[node.status] }}</span>
|
||||
</header>
|
||||
<div class="whitelist-row">
|
||||
<span v-if="node.is_whitelist" class="pill pending">白名单</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" :checked="node.is_whitelist" @change="toggleWhitelist(node)" />
|
||||
<span></span>
|
||||
白名单
|
||||
</label>
|
||||
<button v-if="node.is_banned" class="texty" type="button" @click="setBan(node, false)">解除封禁</button>
|
||||
<button v-else class="danger" type="button" @click="pendingBan = node">封禁</button>
|
||||
</div>
|
||||
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
|
||||
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
|
||||
<p class="wallet">{{ node.wallet_address }}</p>
|
||||
<p class="sub">{{ node.sn }}</p>
|
||||
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线率</p>
|
||||
<dl>
|
||||
<div><dt>显卡</dt><dd>{{ gpuText(node) }}</dd></div>
|
||||
<div><dt>CPU</dt><dd>{{ node.cpu_count ? `${node.cpu_count} 核` : "—" }}</dd></div>
|
||||
<div><dt>内存</dt><dd>{{ node.memory_gb ? `${node.memory_gb} GB` : "—" }}</dd></div>
|
||||
<div><dt>权重</dt><dd>{{ node.hardware_weight }}</dd></div>
|
||||
<div><dt>世代</dt><dd>{{ generationText(node) }}</dd></div>
|
||||
<div><dt>绑定 IP</dt><dd>{{ node.client_ip || "—" }}</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="!nodes.length" class="empty">还没有节点。</p>
|
||||
</section>
|
||||
|
||||
<div v-else-if="activeTab === 'tasks'">
|
||||
<form class="panel" @submit.prevent="createTask">
|
||||
<h2>发布任务</h2>
|
||||
<p class="sub">指定节点会进入该节点的领取队列。选择全部在线节点时,当前每个在线节点各收到一条。</p>
|
||||
<div class="forms">
|
||||
<label>任务类型
|
||||
<select v-model="taskForm.task_type" @change="fillTaskSample">
|
||||
<option v-for="item in taskTypes" :key="item.id" :value="item.id">{{ item.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>目标节点
|
||||
<select v-model="taskForm.target_node_sn">
|
||||
<option value="">全部在线节点</option>
|
||||
<option v-for="node in nodes" :key="node.sn" :value="node.sn">{{ node.display_node_id || node.sn }} · {{ node.sn }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="span-2">Payload
|
||||
<textarea v-model="taskForm.payloadText" required spellcheck="false" @input="payloadDirty = true" />
|
||||
</label>
|
||||
</div>
|
||||
<button class="gold" type="submit">立即下发任务</button>
|
||||
</form>
|
||||
<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 dispatched" :key="row.task_id">
|
||||
<td class="mono" :title="row.task_id">{{ row.task_id.slice(0, 8) }}</td>
|
||||
<td>{{ taskTypeText(row.task_type) }}</td>
|
||||
<td>{{ row.display_node_id || "—" }} · {{ row.target_node_sn }}</td>
|
||||
<td><span class="pill" :class="row.status">{{ taskStatusText[row.status] || row.status }}</span></td>
|
||||
<td>{{ when(row.responded_at) }}</td>
|
||||
<td class="mono">{{ row.result_preview || "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!dispatched.length"><td colspan="6" class="empty">还没有任务。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-else-if="activeTab === 'whitelist'" class="panel whitelist-panel">
|
||||
<p class="sub">可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。</p>
|
||||
<form class="ip-form" @submit.prevent="addIp">
|
||||
<label>豁免 IP / 网段<input v-model="ipForm.ip_address" required placeholder="203.0.113.0/24" /></label>
|
||||
<label>备注<input v-model="ipForm.note" placeholder="自营机房出口" /></label>
|
||||
<button class="gold" type="submit">添加豁免 IP</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>IP</th><th>备注</th><th>添加时间</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in whitelist" :key="row.id">
|
||||
<td class="mono">{{ row.ip_address }}</td>
|
||||
<td>{{ row.note || "—" }}</td>
|
||||
<td>{{ when(row.created_at) }}</td>
|
||||
<td class="actions"><button class="texty" type="button" @click="removeIp(row)">移除</button></td>
|
||||
</tr>
|
||||
<tr v-if="!whitelist.length"><td colspan="4" class="empty">还没有豁免 IP。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-else-if="activeTab === 'datacenter-apps'" class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>单号</th><th>钱包</th><th>联系方式</th><th>节点数</th><th>网段</th><th>显卡</th><th>证明</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in applications" :key="row.id">
|
||||
<td>#{{ row.id }}</td>
|
||||
<td class="mono">{{ row.wallet_address }}</td>
|
||||
<td>{{ row.contact }}</td>
|
||||
<td>
|
||||
{{ row.node_count }}
|
||||
<span class="pill" :class="row.node_count >= 5 ? 'pending' : 'rejected'">{{ row.node_count >= 5 ? "≥ 5 台" : "不足 5 台" }}</span>
|
||||
</td>
|
||||
<td class="mono">{{ row.subnet_ip }}</td>
|
||||
<td>{{ row.gpu_generation }} 系</td>
|
||||
<td>{{ row.hardware_proof }}</td>
|
||||
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
|
||||
<td class="actions" v-if="row.status === 'pending'">
|
||||
<button class="gold" type="button" @click="approveDatacenter(row)">审批通过</button>
|
||||
<button class="texty" type="button" @click="rejectDatacenter(row)">拒绝</button>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</tr>
|
||||
<tr v-if="!applications.length"><td colspan="9" class="empty">还没有机房申请。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'withdrawals'">
|
||||
<p class="sub tab-note">折算金额按当前基准 1 积分 ≈ 0.95 USDT。实际打款以链上哈希为准。</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>单号</th><th>USDT 地址</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>{{ usdtFromPoints(row.amount) }} USDT</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="7" class="empty">还没有提现申请。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'users'" 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>
|
||||
<tr v-if="!users.length"><td colspan="6" class="empty">还没有用户。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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><th>证明</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in applications" :key="row.id">
|
||||
<td>#{{ row.id }}</td>
|
||||
<td class="mono">{{ row.wallet_address }}</td>
|
||||
<td>{{ row.contact }}</td>
|
||||
<td>{{ row.node_count }}</td>
|
||||
<td class="mono">{{ row.subnet_ip }}</td>
|
||||
<td>{{ row.gpu_generation }} 系</td>
|
||||
<td>{{ row.hardware_proof }}</td>
|
||||
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
|
||||
<td class="actions" v-if="row.status === 'pending'">
|
||||
<button class="gold" type="button" @click="approveDatacenter(row)">通过</button>
|
||||
<button class="texty" type="button" @click="rejectDatacenter(row)">拒绝</button>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</tr>
|
||||
<tr v-if="!applications.length"><td colspan="9" 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="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>
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import Shell from "../components/Shell.vue";
|
||||
import { api, download } from "../api";
|
||||
import { money, pct, statusText, when } from "../format";
|
||||
import { money, pct, statusText, usdtFromPoints, when } from "../format";
|
||||
|
||||
const tabs = [
|
||||
{ id: "nodes", label: "我的节点" },
|
||||
{ id: "orders", label: "授权订单" },
|
||||
{ id: "withdrawals", label: "申请提现" },
|
||||
{ id: "datacenter", label: "机房申请" },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const activeTab = computed(() => {
|
||||
const id = String(route.params.tab || "");
|
||||
return tabs.some((tab) => tab.id === id) ? id : "";
|
||||
});
|
||||
|
||||
const board = ref(null);
|
||||
const error = ref("");
|
||||
@@ -96,6 +111,14 @@ function requestCash() {
|
||||
}, "提现申请已提交");
|
||||
}
|
||||
|
||||
watch(
|
||||
activeTab,
|
||||
(id) => {
|
||||
if (!id) router.replace("/dashboard/nodes");
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await load();
|
||||
@@ -135,88 +158,132 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<p class="fine">* 算力积分兑换汇率:1 积分 = 0.8 ~ 1.0 USDT(根据全网算力动态浮动,当前基准约 0.95 USDT)</p>
|
||||
</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.display_node_id" class="sub">节点 {{ order.display_node_id }}</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.display_node_id || node.sn }}</strong>
|
||||
<span class="pill" :class="node.activated ? node.status : 'pending'"><i />{{ node.activated ? statusText[node.status] : "待审核" }}</span>
|
||||
</header>
|
||||
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
|
||||
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
|
||||
<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>
|
||||
<div><dt>显卡</dt><dd>{{ gpuText(node) }}</dd></div>
|
||||
<div><dt>硬件权重</dt><dd>{{ node.hardware_weight }}</dd></div>
|
||||
<div><dt>显卡世代</dt><dd>{{ generationText(node) }}</dd></div>
|
||||
<div><dt>回本周期</dt><dd>{{ node.recovery_days }} 天</dd></div>
|
||||
<div><dt>待返还质押</dt><dd>{{ money(node.recoverable_stake_amount || 0) }}</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 && !board.orders.length" class="empty">还没有节点。下面可以购买授权。</p>
|
||||
</section>
|
||||
<nav class="admin-tabs" role="tablist" aria-label="我的节点">
|
||||
<router-link
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
role="tab"
|
||||
:to="`/dashboard/${tab.id}`"
|
||||
:aria-selected="activeTab === tab.id"
|
||||
:class="{ on: activeTab === tab.id }"
|
||||
>{{ tab.label }}</router-link>
|
||||
</nav>
|
||||
|
||||
<section class="forms">
|
||||
<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>
|
||||
<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>
|
||||
<form class="panel span-2" @submit.prevent="applyDatacenter">
|
||||
<h2>机房白名单申请</h2>
|
||||
<p class="sub">同一公网出口批量接入,需至少 5 台且全部为 30 系或 40 系显卡。审核通过后,该网段内硬件指纹不同的机器可以一起挂载。</p>
|
||||
<label>联系方式<input v-model="datacenterForm.contact" required minlength="4" placeholder="Telegram / 邮箱 / 手机" /></label>
|
||||
<label>预估节点数<input v-model="datacenterForm.node_count" required type="number" min="5" placeholder="5" /></label>
|
||||
<label>出口网段<input v-model="datacenterForm.subnet_ip" required placeholder="203.0.113.0/24" /></label>
|
||||
<label>显卡世代
|
||||
<select v-model="datacenterForm.gpu_generation">
|
||||
<option value="40">40 系及以上</option>
|
||||
<option value="30">30 系</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>硬件配置证明<textarea v-model="datacenterForm.hardware_proof" required minlength="4" placeholder="例如 8 台 RTX 4090,附采购或机房照片说明" /></label>
|
||||
<button class="gold" type="submit">提交机房申请</button>
|
||||
<ul class="mini">
|
||||
<li v-for="row in board?.datacenter_applications || []" :key="row.id">
|
||||
<span>#{{ row.id }} · {{ row.subnet_ip }} · {{ row.node_count }} 台</span>
|
||||
<span class="pill" :class="row.status">{{ statusText[row.status] }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
<section class="tab-panel" role="tabpanel">
|
||||
<p v-if="!board" class="empty">正在读取…</p>
|
||||
|
||||
<section v-else-if="activeTab === 'nodes'" class="board admin-board">
|
||||
<article v-for="node in board.nodes" :key="node.sn" class="plate">
|
||||
<header>
|
||||
<strong>{{ node.display_node_id || node.sn }}</strong>
|
||||
<span class="pill" :class="node.is_banned ? 'rejected' : (node.activated ? node.status : 'pending')"><i />{{ node.is_banned ? "已封禁" : (node.activated ? statusText[node.status] : "待审核") }}</span>
|
||||
</header>
|
||||
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
|
||||
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
|
||||
<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>
|
||||
<div><dt>显卡</dt><dd>{{ gpuText(node) }}</dd></div>
|
||||
<div><dt>硬件权重</dt><dd>{{ node.hardware_weight }}</dd></div>
|
||||
<div><dt>显卡世代</dt><dd>{{ generationText(node) }}</dd></div>
|
||||
<div><dt>回本周期</dt><dd>{{ node.recovery_days }} 天</dd></div>
|
||||
<div><dt>待返还质押</dt><dd>{{ money(node.recoverable_stake_amount || 0) }}</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.nodes.length" class="empty">还没有节点。到「授权订单」提交支付凭证。</p>
|
||||
</section>
|
||||
|
||||
<div v-else-if="activeTab === 'orders'">
|
||||
<form class="panel" @submit.prevent="placeOrder">
|
||||
<h2>购买节点授权</h2>
|
||||
<p class="sub">{{ money(board.license_price) }} {{ 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>
|
||||
<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.display_node_id" class="sub">节点 {{ order.display_node_id }}</p>
|
||||
</article>
|
||||
<p v-if="!board.orders.length" class="empty">还没有授权订单。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'withdrawals'">
|
||||
<form class="panel" @submit.prevent="requestCash">
|
||||
<h2>申请提现</h2>
|
||||
<p class="sub">提交申请后,相应积分进入锁定。审核通过后打到注册时填写的 USDT 地址。折算按当前基准 1 积分 ≈ 0.95 USDT。</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>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>单号</th><th>积分</th><th>折算金额</th><th>状态</th><th>时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in board.withdrawals" :key="row.id">
|
||||
<td>#{{ row.id }}</td>
|
||||
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
|
||||
<td>{{ usdtFromPoints(row.amount) }} USDT</td>
|
||||
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
|
||||
<td>{{ when(row.created_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!board.withdrawals.length"><td colspan="5" class="empty">还没有提现申请。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'datacenter'">
|
||||
<form class="panel" @submit.prevent="applyDatacenter">
|
||||
<h2>机房白名单申请</h2>
|
||||
<p class="sub">同一公网出口批量接入,需至少 5 台且全部为 30 系或 40 系显卡。审核通过后,该网段内硬件指纹不同的机器可以一起挂载。</p>
|
||||
<div class="forms">
|
||||
<label>联系方式<input v-model="datacenterForm.contact" required minlength="4" placeholder="Telegram / 邮箱 / 手机" /></label>
|
||||
<label>预估节点数<input v-model="datacenterForm.node_count" required type="number" min="5" placeholder="5" /></label>
|
||||
<label>出口网段<input v-model="datacenterForm.subnet_ip" required placeholder="203.0.113.0/24" /></label>
|
||||
<label>显卡世代
|
||||
<select v-model="datacenterForm.gpu_generation">
|
||||
<option value="40">40 系及以上</option>
|
||||
<option value="30">30 系</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="span-2">硬件配置证明<textarea v-model="datacenterForm.hardware_proof" required minlength="4" placeholder="例如 8 台 RTX 4090,附采购或机房照片说明" /></label>
|
||||
</div>
|
||||
<button class="gold" type="submit">提交机房申请</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>单号</th><th>网段</th><th>节点数</th><th>显卡</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in board.datacenter_applications" :key="row.id">
|
||||
<td>#{{ row.id }}</td>
|
||||
<td class="mono">{{ row.subnet_ip }}</td>
|
||||
<td>{{ row.node_count }}</td>
|
||||
<td>{{ row.gpu_generation }} 系</td>
|
||||
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
|
||||
</tr>
|
||||
<tr v-if="!board.datacenter_applications.length"><td colspan="5" class="empty">还没有机房申请。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Shell>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user