1572 lines
54 KiB
JavaScript
1572 lines
54 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
const cfg = window.ADMIN_APP_CONFIG || {};
|
||
const root = document.getElementById("admin-app");
|
||
|
||
if (!root) {
|
||
return;
|
||
}
|
||
|
||
const state = {
|
||
token: readToken(),
|
||
user: null,
|
||
route: "overview",
|
||
busy: false,
|
||
message: "",
|
||
messageType: "info",
|
||
modal: null,
|
||
configTab: "site",
|
||
sidebarGroups: {
|
||
dashboard: true,
|
||
infrastructure: true,
|
||
business: true,
|
||
operations: true,
|
||
system: true
|
||
},
|
||
pagination: {},
|
||
dashboard: null,
|
||
system: null,
|
||
config: null,
|
||
nodes: [],
|
||
groups: [],
|
||
routes: [],
|
||
plans: [],
|
||
orders: { list: [], pagination: null },
|
||
coupons: [],
|
||
users: { list: [], pagination: null },
|
||
tickets: { list: [], pagination: null },
|
||
realname: { list: [], pagination: null },
|
||
devices: { list: [], pagination: null },
|
||
ipv6: { list: [], pagination: null },
|
||
expandedNodes: new Set()
|
||
};
|
||
|
||
const ROUTE_META = {
|
||
overview: { title: "总览", description: "查看收入、用户和流量概况。" },
|
||
"dashboard-node": { title: "节点状态", description: "查看节点在线状态、负载和推送情况。" },
|
||
"node-manage": { title: "节点管理", description: "管理服务节点、可见性以及父子节点关系。" },
|
||
"node-group": { title: "权限组", description: "管理节点权限组和用户分组映射。" },
|
||
"node-route": { title: "路由规则", description: "维护节点路由匹配规则。" },
|
||
"plan-manage": { title: "套餐管理", description: "维护套餐、流量和价格配置。" },
|
||
"order-manage": { title: "订单管理", description: "处理待支付和已支付订单。" },
|
||
"coupon-manage": { title: "优惠券", description: "创建和维护优惠券信息。" },
|
||
"user-manage": { title: "用户管理", description: "查看用户订阅、流量和封禁状态。" },
|
||
"ticket-manage": { title: "工单中心", description: "查看用户工单和处理状态。" },
|
||
realname: { title: "实名认证", description: "审核实名记录和同步状态。" },
|
||
"user-online-devices": { title: "在线设备", description: "查看用户在线 IP 和设备分布。" },
|
||
"user-ipv6-subscription": { title: "IPv6 子账号", description: "管理 IPv6 阴影账号与密码同步。" },
|
||
"system-config": { title: "系统设置", description: "编辑站点、订阅和安全参数。" }
|
||
};
|
||
|
||
state.route = normalizeRoute(readRoute());
|
||
|
||
boot();
|
||
|
||
async function boot() {
|
||
window.addEventListener("hashchange", async function () {
|
||
state.route = normalizeRoute(readRoute());
|
||
state.modal = null;
|
||
await hydrateRoute();
|
||
});
|
||
|
||
root.addEventListener("click", onClick);
|
||
root.addEventListener("submit", onSubmit);
|
||
|
||
if (state.token) {
|
||
await loadBootstrap();
|
||
} else {
|
||
render();
|
||
}
|
||
|
||
if (state.user) {
|
||
await hydrateRoute();
|
||
} else {
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function loadBootstrap() {
|
||
try {
|
||
setBusy(true);
|
||
const loginCheck = unwrap(await request("/api/v1/user/checkLogin", { method: "GET" }));
|
||
if (!loginCheck || !loginCheck.is_admin) {
|
||
clearSession();
|
||
return;
|
||
}
|
||
|
||
state.user = loginCheck;
|
||
const [config, system] = await Promise.all([
|
||
request(cfg.api.adminConfig, { method: "GET" }),
|
||
request(cfg.api.systemStatus, { method: "GET" })
|
||
]);
|
||
state.config = unwrap(config) || {};
|
||
state.system = unwrap(system) || {};
|
||
} catch (error) {
|
||
console.error("bootstrap failed", error);
|
||
clearSession();
|
||
show(error.message || "管理端初始化失败", "error");
|
||
} finally {
|
||
setBusy(false);
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function hydrateRoute() {
|
||
if (!state.user) {
|
||
render();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setBusy(true);
|
||
const page = getCurrentPage();
|
||
|
||
if (state.route === "overview") {
|
||
state.dashboard = unwrap(await request(cfg.api.dashboardSummary));
|
||
} else if (state.route === "dashboard-node") {
|
||
const [dashboard, nodes] = await Promise.all([
|
||
request(cfg.api.dashboardSummary),
|
||
request(cfg.api.serverNodes)
|
||
]);
|
||
state.dashboard = unwrap(dashboard) || {};
|
||
state.nodes = toArray(unwrap(nodes));
|
||
} else if (state.route === "node-manage") {
|
||
const [nodes, groups] = await Promise.all([
|
||
request(cfg.api.serverNodes),
|
||
request(cfg.api.serverGroups)
|
||
]);
|
||
state.nodes = toArray(unwrap(nodes));
|
||
state.groups = toArray(unwrap(groups));
|
||
} else if (state.route === "node-group") {
|
||
state.groups = toArray(unwrap(await request(cfg.api.serverGroups)));
|
||
} else if (state.route === "node-route") {
|
||
state.routes = toArray(unwrap(await request(cfg.api.serverRoutes)));
|
||
} else if (state.route === "plan-manage") {
|
||
const [plans, groups] = await Promise.all([
|
||
request(cfg.api.plans),
|
||
request(cfg.api.serverGroups)
|
||
]);
|
||
state.plans = toArray(unwrap(plans));
|
||
state.groups = toArray(unwrap(groups));
|
||
} else if (state.route === "order-manage") {
|
||
const payload = await request(`${cfg.api.orders}?page=${page}&per_page=20`);
|
||
state.orders = normalizeListPayload(payload);
|
||
} else if (state.route === "coupon-manage") {
|
||
state.coupons = toArray(unwrap(await request(cfg.api.coupons)));
|
||
} else if (state.route === "user-manage") {
|
||
const [users, plans, groups] = await Promise.all([
|
||
request(`${cfg.api.users}?page=${page}&per_page=20`),
|
||
request(cfg.api.plans),
|
||
request(cfg.api.serverGroups)
|
||
]);
|
||
state.users = normalizeListPayload(users);
|
||
state.plans = toArray(unwrap(plans));
|
||
state.groups = toArray(unwrap(groups));
|
||
} else if (state.route === "ticket-manage") {
|
||
const payload = await request(`${cfg.api.tickets}?page=${page}&per_page=20`);
|
||
state.tickets = normalizeListPayload(payload);
|
||
} else if (state.route === "realname") {
|
||
const payload = await request(`${cfg.api.realnameBase}/records?page=${page}&per_page=20`);
|
||
state.realname = normalizeListPayload(payload, "data");
|
||
} else if (state.route === "user-online-devices") {
|
||
const payload = await request(`${cfg.api.onlineDevices}?page=${page}&per_page=20`);
|
||
state.devices = normalizeListPayload(payload);
|
||
} else if (state.route === "user-ipv6-subscription") {
|
||
const [payload, plans] = await Promise.all([
|
||
request(`${cfg.api.ipv6Base}/users?page=${page}&per_page=20`),
|
||
request(cfg.api.plans)
|
||
]);
|
||
state.ipv6 = normalizeListPayload(payload);
|
||
state.plans = toArray(unwrap(plans));
|
||
} else if (state.route === "system-config") {
|
||
state.config = unwrap(await request(cfg.api.adminConfig));
|
||
}
|
||
} catch (error) {
|
||
console.error("route hydrate failed", error);
|
||
show(error.message || "页面数据加载失败", "error");
|
||
} finally {
|
||
setBusy(false);
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function onClick(event) {
|
||
const actionEl = event.target.closest("[data-action]");
|
||
if (!actionEl || state.busy) {
|
||
return;
|
||
}
|
||
|
||
const action = actionEl.getAttribute("data-action");
|
||
|
||
if (action === "nav") {
|
||
window.location.hash = actionEl.getAttribute("data-route") || "overview";
|
||
return;
|
||
}
|
||
|
||
if (action === "logout") {
|
||
clearSession();
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "refresh") {
|
||
await loadBootstrap();
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "modal-close") {
|
||
state.modal = null;
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "sidebar-toggle") {
|
||
const group = actionEl.getAttribute("data-id");
|
||
state.sidebarGroups[group] = state.sidebarGroups[group] === false;
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "config-tab") {
|
||
state.configTab = actionEl.getAttribute("data-tab") || "site";
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "page") {
|
||
setCurrentPage(Number(actionEl.getAttribute("data-page")) || 1);
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "node-expand") {
|
||
const id = String(actionEl.getAttribute("data-id"));
|
||
if (state.expandedNodes.has(id)) {
|
||
state.expandedNodes.delete(id);
|
||
} else {
|
||
state.expandedNodes.add(id);
|
||
}
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "plan-add") {
|
||
state.modal = { type: "plan", data: {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "plan-edit") {
|
||
const item = state.plans.find((row) => String(row.id) === actionEl.getAttribute("data-id"));
|
||
state.modal = { type: "plan", data: item || {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "plan-delete") {
|
||
if (confirm("确认删除该套餐吗?")) {
|
||
await adminPost(`${cfg.api.adminBase}/plan/drop`, { id: Number(actionEl.getAttribute("data-id")) });
|
||
await hydrateRoute();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "coupon-add") {
|
||
state.modal = { type: "coupon", data: {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "coupon-delete") {
|
||
if (confirm("确认删除该优惠券吗?")) {
|
||
await adminPost(`${cfg.api.adminBase}/coupon/drop`, { id: Number(actionEl.getAttribute("data-id")) });
|
||
await hydrateRoute();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "user-edit") {
|
||
const item = state.users.list.find((row) => String(row.id) === actionEl.getAttribute("data-id"));
|
||
state.modal = { type: "user", data: item || {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "user-ban" || action === "user-unban") {
|
||
await adminPost(`${cfg.api.adminBase}/user/ban`, {
|
||
id: Number(actionEl.getAttribute("data-id")),
|
||
banned: action === "user-ban"
|
||
});
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "user-reset-traffic") {
|
||
await adminPost(`${cfg.api.adminBase}/user/resetTraffic`, {
|
||
id: Number(actionEl.getAttribute("data-id"))
|
||
});
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "order-paid") {
|
||
await adminPost(`${cfg.api.adminBase}/order/paid`, {
|
||
trade_no: actionEl.getAttribute("data-trade")
|
||
});
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "order-cancel") {
|
||
await adminPost(`${cfg.api.adminBase}/order/cancel`, {
|
||
trade_no: actionEl.getAttribute("data-trade")
|
||
});
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "node-add") {
|
||
state.modal = { type: "node", data: {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "node-edit") {
|
||
const item = state.nodes.find((row) => String(row.id) === actionEl.getAttribute("data-id"));
|
||
state.modal = { type: "node", data: item || {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "node-copy") {
|
||
const nodeId = Number(actionEl.getAttribute("data-id"));
|
||
try {
|
||
await adminPost(`${cfg.api.adminBase}/server/manage/copy`, { id: nodeId });
|
||
} catch (e) { /* adminPost already shows error */ }
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "node-delete") {
|
||
if (confirm("确认删除该节点吗?")) {
|
||
const nodeId = Number(actionEl.getAttribute("data-id"));
|
||
try {
|
||
await adminPost(`${cfg.api.adminBase}/server/manage/drop`, { id: nodeId });
|
||
} catch (e) { /* adminPost already shows error */ }
|
||
await hydrateRoute();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "node-toggle-visible") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const nodeId = Number(actionEl.getAttribute("data-id"));
|
||
const input = actionEl.querySelector("input");
|
||
if (!input) {
|
||
return;
|
||
}
|
||
const newShow = !input.checked;
|
||
input.checked = newShow;
|
||
try {
|
||
await adminPost(`${cfg.api.adminBase}/server/manage/update`, {
|
||
id: nodeId,
|
||
show: newShow ? 1 : 0
|
||
});
|
||
} catch (e) { /* adminPost already shows error */ }
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "group-add") {
|
||
state.modal = { type: "group", data: {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "group-edit") {
|
||
const item = state.groups.find((row) => String(row.id) === actionEl.getAttribute("data-id"));
|
||
state.modal = { type: "group", data: item || {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "group-delete") {
|
||
if (confirm("确认删除该权限组吗?")) {
|
||
await adminPost(`${cfg.api.adminBase}/server/group/drop`, {
|
||
id: Number(actionEl.getAttribute("data-id"))
|
||
});
|
||
await hydrateRoute();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "route-add") {
|
||
state.modal = { type: "route", data: {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "route-edit") {
|
||
const item = state.routes.find((row) => String(row.id) === actionEl.getAttribute("data-id"));
|
||
state.modal = { type: "route", data: item || {} };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "route-delete") {
|
||
if (confirm("确认删除该路由规则吗?")) {
|
||
await adminPost(`${cfg.api.adminBase}/server/route/drop`, {
|
||
id: Number(actionEl.getAttribute("data-id"))
|
||
});
|
||
await hydrateRoute();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "realname-review") {
|
||
const userId = actionEl.getAttribute("data-user-id");
|
||
const status = actionEl.getAttribute("data-status");
|
||
const reason = status === "rejected" ? prompt("请输入驳回原因,可留空:", "") || "" : "";
|
||
await adminPost(`${cfg.api.realnameBase}/review/${userId}`, { status, reason });
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "approve-all") {
|
||
await adminPost(`${cfg.api.realnameBase}/approve-all`, {});
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "sync-all") {
|
||
await adminPost(`${cfg.api.realnameBase}/sync-all`, {});
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
|
||
if (action === "ipv6-enable-modal") {
|
||
const userId = actionEl.getAttribute("data-user-id");
|
||
const userEmail = actionEl.getAttribute("data-email") || "";
|
||
state.modal = { type: "ipv6-enable", data: { userId, email: userEmail } };
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (action === "ipv6-disable") {
|
||
const userId = actionEl.getAttribute("data-user-id");
|
||
if (confirm("确认关闭该用户的 IPv6 子账号?(软禁用,可恢复)")) {
|
||
await adminPost(`${cfg.api.ipv6Base}/disable/${userId}`, {});
|
||
await hydrateRoute();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (action === "ipv6-sync-password") {
|
||
const userId = actionEl.getAttribute("data-user-id");
|
||
await adminPost(`${cfg.api.ipv6Base}/sync-password/${userId}`, {});
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
}
|
||
|
||
async function onSubmit(event) {
|
||
const form = event.target;
|
||
const action = form.getAttribute("data-form");
|
||
if (!action) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
|
||
if (action === "login") {
|
||
try {
|
||
setBusy(true);
|
||
const payload = unwrap(await request("/api/v2/passport/auth/login", {
|
||
method: "POST",
|
||
auth: false,
|
||
body: serializeForm(form)
|
||
}));
|
||
if (!payload || !payload.auth_data || !payload.is_admin) {
|
||
throw new Error("当前账号没有管理员权限");
|
||
}
|
||
saveToken(payload.auth_data);
|
||
state.token = readToken();
|
||
await loadBootstrap();
|
||
await hydrateRoute();
|
||
show("登录成功", "success");
|
||
} catch (error) {
|
||
show(error.message || "登录失败", "error");
|
||
render();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const body = serializeForm(form);
|
||
let target = "";
|
||
|
||
if (action === "plan-save") {
|
||
target = `${cfg.api.adminBase}/plan/save`;
|
||
} else if (action === "coupon-save") {
|
||
target = `${cfg.api.adminBase}/coupon/save`;
|
||
} else if (action === "user-save") {
|
||
target = `${cfg.api.adminBase}/user/update`;
|
||
} else if (action === "node-save") {
|
||
target = `${cfg.api.adminBase}/server/manage/save`;
|
||
} else if (action === "group-save") {
|
||
target = `${cfg.api.adminBase}/server/group/save`;
|
||
} else if (action === "route-save") {
|
||
target = `${cfg.api.adminBase}/server/route/save`;
|
||
} else if (action === "config-save") {
|
||
target = `${cfg.api.adminBase}/config/save`;
|
||
}
|
||
|
||
if (!target) {
|
||
if (action === "ipv6-enable-save") {
|
||
const userId = body.user_id;
|
||
const planId = Number(body.plan_id) || 0;
|
||
await adminPost(`${cfg.api.ipv6Base}/enable/${userId}`, { plan_id: planId });
|
||
state.modal = null;
|
||
await hydrateRoute();
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
|
||
await adminPost(target, body);
|
||
if (action !== "config-save") {
|
||
state.modal = null;
|
||
}
|
||
await hydrateRoute();
|
||
}
|
||
|
||
function render() {
|
||
root.innerHTML = state.user ? renderDashboard() : renderLogin();
|
||
if (state.modal) {
|
||
const modalShell = document.createElement("div");
|
||
modalShell.className = "modal-overlay";
|
||
modalShell.innerHTML = renderModalContent();
|
||
root.appendChild(modalShell);
|
||
}
|
||
if (state.busy) {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "busy-overlay";
|
||
overlay.innerHTML = '<div class="busy-spinner">正在处理...</div>';
|
||
root.appendChild(overlay);
|
||
}
|
||
}
|
||
|
||
function renderLogin() {
|
||
return [
|
||
'<div class="login-container">',
|
||
'<div class="login-card">',
|
||
'<div class="login-header">',
|
||
`<h1>${escapeHtml(cfg.title || "SingBox 管理后台")}</h1>`,
|
||
'<p>使用管理员账号登录 Go 重构后的控制台</p>',
|
||
"</div>",
|
||
state.message ? renderNotice() : "",
|
||
'<form data-form="login" class="login-form">',
|
||
'<div class="login-field"><label>邮箱</label><div class="input-wrapper"><input type="email" name="email" placeholder="admin@example.com" required autocomplete="email" /></div></div>',
|
||
'<div class="login-field"><label>密码</label><div class="input-wrapper"><input type="password" name="password" placeholder="请输入密码" required autocomplete="current-password" /></div></div>',
|
||
'<button type="submit" class="btn btn-primary btn-login">登录管理后台</button>',
|
||
"</form>",
|
||
"</div>",
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
function renderDashboard() {
|
||
return [
|
||
'<div class="admin-shell">',
|
||
renderSidebar(),
|
||
'<main class="admin-main">',
|
||
renderTopbar(),
|
||
'<div class="page-shell">',
|
||
state.message ? renderNotice() : "",
|
||
renderMainContent(),
|
||
"</div>",
|
||
"</main>",
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
function renderSidebar() {
|
||
return [
|
||
'<aside class="admin-sidebar">',
|
||
'<div class="sidebar-header">',
|
||
'<div class="sidebar-brand">',
|
||
`<strong>${escapeHtml(cfg.title || "Admin")}</strong>`,
|
||
`<span>Go Control Plane v${escapeHtml(cfg.version || "1.0.0")}</span>`,
|
||
"</div>",
|
||
"</div>",
|
||
'<div class="sidebar-nav">',
|
||
renderSidebarGroup("dashboard", "概览", [
|
||
navItem("overview", "总览", "收入、用户、流量"),
|
||
navItem("dashboard-node", "节点状态", "实时在线与负载")
|
||
]),
|
||
renderSidebarGroup("infrastructure", "基础设施", [
|
||
navItem("node-manage", "节点管理", "服务节点与层级"),
|
||
navItem("node-group", "权限组", "套餐与节点分组"),
|
||
navItem("node-route", "路由规则", "流量匹配动作")
|
||
]),
|
||
renderSidebarGroup("business", "运营", [
|
||
navItem("plan-manage", "套餐管理", "流量与价格"),
|
||
navItem("order-manage", "订单管理", "支付与开通"),
|
||
navItem("coupon-manage", "优惠券", "营销优惠")
|
||
]),
|
||
renderSidebarGroup("operations", "用户", [
|
||
navItem("user-manage", "用户管理", "账号、订阅、流量"),
|
||
navItem("realname", "实名认证", "实名审核"),
|
||
navItem("user-online-devices", "在线设备", "在线 IP 与会话"),
|
||
navItem("user-ipv6-subscription", "IPv6 子账号", "开通与密码同步"),
|
||
navItem("ticket-manage", "工单中心", "用户支持")
|
||
]),
|
||
renderSidebarGroup("system", "系统", [
|
||
navItem("system-config", "系统设置", "站点与安全参数")
|
||
]),
|
||
"</div>",
|
||
'<div class="sidebar-footer">',
|
||
'<button class="btn btn-secondary sidebar-footer-btn" data-action="logout">退出登录</button>',
|
||
"</div>",
|
||
"</aside>"
|
||
].join("");
|
||
}
|
||
|
||
function renderSidebarGroup(id, label, items) {
|
||
const expanded = state.sidebarGroups[id] !== false;
|
||
return [
|
||
'<div class="sidebar-nav-group">',
|
||
`<button class="sidebar-nav-item-header" data-action="sidebar-toggle" data-id="${escapeHtml(id)}">`,
|
||
`<span>${escapeHtml(label)}</span>`,
|
||
`<span class="sidebar-arrow ${expanded ? "expanded" : ""}">›</span>`,
|
||
"</button>",
|
||
`<div class="sidebar-submenu ${expanded ? "" : "collapsed"}">`,
|
||
items.join(""),
|
||
"</div>",
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
function navItem(route, title, desc) {
|
||
const active = state.route === route ? "active" : "";
|
||
return [
|
||
`<a class="sidebar-item ${active}" data-action="nav" data-route="${escapeHtml(route)}" href="#${escapeHtml(route)}">`,
|
||
`<strong>${escapeHtml(title)}</strong>`,
|
||
`<span>${escapeHtml(desc)}</span>`,
|
||
"</a>"
|
||
].join("");
|
||
}
|
||
|
||
function renderTopbar() {
|
||
const meta = ROUTE_META[state.route] || {
|
||
title: getRouteTitle(state.route),
|
||
description: "管理后台页面"
|
||
};
|
||
return [
|
||
'<header class="topbar card">',
|
||
'<div class="topbar-copy">',
|
||
`<h1>${escapeHtml(meta.title)}</h1>`,
|
||
`<p>${escapeHtml(meta.description)}</p>`,
|
||
'<div class="topbar-meta">',
|
||
`<span class="topbar-chip">路径 /${escapeHtml(getSecurePath())}</span>`,
|
||
`<span class="topbar-chip">${escapeHtml(state.user.email || "")}</span>`,
|
||
`<span class="topbar-chip">${escapeHtml(formatDate(state.system && state.system.server_time))}</span>`,
|
||
"</div>",
|
||
"</div>",
|
||
'<div class="toolbar"><button class="btn btn-secondary" data-action="refresh">刷新数据</button></div>',
|
||
"</header>"
|
||
].join("");
|
||
}
|
||
|
||
function renderMainContent() {
|
||
if (state.route === "overview") return renderOverview();
|
||
if (state.route === "dashboard-node") return renderDashboardNodes();
|
||
if (state.route === "node-manage") return renderNodeManage();
|
||
if (state.route === "node-group") return renderNodeGroup();
|
||
if (state.route === "node-route") return renderNodeRoute();
|
||
if (state.route === "plan-manage") return renderPlanManage();
|
||
if (state.route === "order-manage") return renderOrderManage();
|
||
if (state.route === "coupon-manage") return renderCouponManage();
|
||
if (state.route === "user-manage") return renderUserManage();
|
||
if (state.route === "ticket-manage") return renderTicketManage();
|
||
if (state.route === "realname") return renderRealnameManage();
|
||
if (state.route === "user-online-devices") return renderOnlineDevices();
|
||
if (state.route === "user-ipv6-subscription") return renderIPv6Manage();
|
||
if (state.route === "system-config") return renderSystemConfig();
|
||
return renderOverview();
|
||
}
|
||
|
||
function renderOverview() {
|
||
const dash = state.dashboard || {};
|
||
return [
|
||
'<div class="grid grid-3">',
|
||
statCard("今日收入", formatMoney(dash.todayIncome), `${formatSignedPercent(dash.dayIncomeGrowth)} 较昨日`),
|
||
statCard("本月收入", formatMoney(dash.currentMonthIncome), `${formatSignedPercent(dash.monthIncomeGrowth)} 较上月`),
|
||
statCard("总用户", String(dash.totalUsers || 0), `本月新增 ${dash.currentMonthNewUsers || 0}`),
|
||
statCard("在线用户", String(dash.onlineUsers || 0), `在线设备 ${dash.onlineDevices || 0}`),
|
||
statCard("待处理工单", String(dash.ticketPendingTotal || 0), `在线节点 ${dash.onlineNodes || 0}`),
|
||
statCard("总流量", formatTraffic(dash.totalTraffic && dash.totalTraffic.total), `今日 ${formatTraffic(dash.todayTraffic && dash.todayTraffic.total)}`),
|
||
"</div>",
|
||
'<section class="card">',
|
||
"<h2>运行概况</h2>",
|
||
'<div class="grid grid-3 compact-grid">',
|
||
summaryItem("应用名称", dash.app_name || cfg.title || "XBoard"),
|
||
summaryItem("管理路径", dash.secure_path || getSecurePath()),
|
||
summaryItem("服务器时间", formatDate(dash.server_time)),
|
||
summaryItem("活跃用户", String(dash.activeUsers || 0)),
|
||
summaryItem("月流量", formatTraffic(dash.monthTraffic && dash.monthTraffic.total)),
|
||
summaryItem("用户增长", formatSignedPercent(dash.userGrowth)),
|
||
"</div>",
|
||
"</section>"
|
||
].join("");
|
||
}
|
||
|
||
function renderDashboardNodes() {
|
||
const rows = (state.nodes || []).map((node) => [
|
||
`<code>${node.id}</code>`,
|
||
escapeHtml(node.name || "-"),
|
||
escapeHtml(node.type || "-"),
|
||
renderStatus(node.available_status || "offline"),
|
||
escapeHtml(`${formatTraffic(node.u)} / ${formatTraffic(node.d)}`),
|
||
escapeHtml(formatDate(node.last_push_at || node.last_check_at))
|
||
]);
|
||
return wrapTable(
|
||
["ID", "节点", "类型", "状态", "上下行", "最后心跳"],
|
||
rows
|
||
);
|
||
}
|
||
|
||
function renderNodeManage() {
|
||
const roots = state.nodes.filter((node) => !node.parent_id);
|
||
const childrenByParent = {};
|
||
state.nodes.forEach((node) => {
|
||
if (node.parent_id) {
|
||
childrenByParent[node.parent_id] = childrenByParent[node.parent_id] || [];
|
||
childrenByParent[node.parent_id].push(node);
|
||
}
|
||
});
|
||
|
||
const rows = [];
|
||
roots.forEach((node) => {
|
||
const children = childrenByParent[node.id] || [];
|
||
const expanded = state.expandedNodes.has(String(node.id));
|
||
rows.push(renderNodeRow(node, false, children.length > 0, expanded));
|
||
if (expanded) {
|
||
children.forEach((child) => rows.push(renderNodeRow(child, true, false, false)));
|
||
}
|
||
});
|
||
|
||
return [
|
||
'<div class="toolbar toolbar-spaced">',
|
||
'<button class="btn btn-primary" data-action="node-add">新增节点</button>',
|
||
"</div>",
|
||
wrapTable(["ID", "名称 / 主机", "类型", "倍率", "显示", "操作"], rows)
|
||
].join("");
|
||
}
|
||
|
||
function renderNodeRow(node, isChild, hasChildren, expanded) {
|
||
const toggle = hasChildren
|
||
? `<button class="node-toggle" data-action="node-expand" data-id="${node.id}">${expanded ? "−" : "+"}</button>`
|
||
: '<span class="node-toggle node-toggle-placeholder"></span>';
|
||
const relationCell = isChild && node.parent_id
|
||
? `<span class="relation-chip"><code>${escapeHtml(node.id)}</code><span class="relation-arrow">→</span><code>${escapeHtml(node.parent_id)}</code></span>`
|
||
: `<code>${node.id}</code>`;
|
||
return {
|
||
className: isChild ? "node-child" : "",
|
||
cells: [
|
||
relationCell,
|
||
[
|
||
'<div class="node-name-block">',
|
||
toggle,
|
||
`<div><strong>${escapeHtml(node.name || "-")}</strong><div class="subtle-text">${escapeHtml(node.host || "-")}</div></div>`,
|
||
"</div>"
|
||
].join(""),
|
||
escapeHtml(node.type || "-"),
|
||
escapeHtml(`${node.rate || 1}x`),
|
||
[
|
||
'<div class="switch-row">',
|
||
renderStatus(node.show ? "visible" : "hidden"),
|
||
`<label class="switch" data-action="node-toggle-visible" data-id="${node.id}"><input type="checkbox" ${node.show ? "checked" : ""}><span class="slider"></span></label>`,
|
||
"</div>"
|
||
].join(""),
|
||
renderActionRow([
|
||
buttonAction("编辑", "node-edit", node.id),
|
||
buttonAction("复制", "node-copy", node.id),
|
||
buttonAction("删除", "node-delete", node.id)
|
||
])
|
||
]
|
||
};
|
||
}
|
||
|
||
function renderNodeGroup() {
|
||
const rows = state.groups.map((group) => [
|
||
`<code>${group.id}</code>`,
|
||
escapeHtml(group.name || "-"),
|
||
`<code>${group.server_count || 0}</code>`,
|
||
`<code>${group.user_count || 0}</code>`,
|
||
renderActionRow([
|
||
buttonAction("编辑", "group-edit", group.id),
|
||
buttonAction("删除", "group-delete", group.id)
|
||
])
|
||
]);
|
||
|
||
return [
|
||
'<div class="toolbar toolbar-spaced"><button class="btn btn-primary" data-action="group-add">新增权限组</button></div>',
|
||
wrapTable(["ID", "名称", "节点数", "用户数", "操作"], rows)
|
||
].join("");
|
||
}
|
||
|
||
function renderNodeRoute() {
|
||
const rows = state.routes.map((route) => [
|
||
`<code>${route.id}</code>`,
|
||
escapeHtml(route.remarks || "-"),
|
||
escapeHtml((route.match || []).join(", ")),
|
||
escapeHtml(route.action || "-"),
|
||
renderActionRow([
|
||
buttonAction("编辑", "route-edit", route.id),
|
||
buttonAction("删除", "route-delete", route.id)
|
||
])
|
||
]);
|
||
|
||
return [
|
||
'<div class="toolbar toolbar-spaced"><button class="btn btn-primary" data-action="route-add">新增路由</button></div>',
|
||
wrapTable(["ID", "备注", "匹配规则", "动作", "操作"], rows)
|
||
].join("");
|
||
}
|
||
|
||
function renderPlanManage() {
|
||
const rows = state.plans.map((plan) => [
|
||
`<code>${plan.id}</code>`,
|
||
escapeHtml(plan.name || "-"),
|
||
escapeHtml(plan.group_name || "-"),
|
||
escapeHtml(formatTraffic(plan.transfer_enable)),
|
||
`<code>${escapeHtml(plan.prices || "{}")}</code>`,
|
||
renderStatus(plan.show ? "visible" : "hidden"),
|
||
renderActionRow([
|
||
buttonAction("编辑", "plan-edit", plan.id),
|
||
buttonAction("删除", "plan-delete", plan.id)
|
||
])
|
||
]);
|
||
|
||
return [
|
||
'<div class="toolbar toolbar-spaced"><button class="btn btn-primary" data-action="plan-add">新增套餐</button></div>',
|
||
wrapTable(["ID", "套餐名称", "权限组", "流量", "价格", "状态", "操作"], rows)
|
||
].join("");
|
||
}
|
||
|
||
function renderOrderManage() {
|
||
const rows = state.orders.list.map((order) => [
|
||
`<code>${escapeHtml(order.trade_no)}</code>`,
|
||
escapeHtml(order.user_email || `#${order.user_id}`),
|
||
escapeHtml((order.plan && order.plan.name) || "-"),
|
||
formatMoney(order.total_amount),
|
||
renderStatus(orderStatusText(order.status)),
|
||
renderActionRow(
|
||
order.status === 0
|
||
? [
|
||
buttonAction("标记已支付", "order-paid", null, `data-trade="${escapeHtml(order.trade_no)}"`),
|
||
buttonAction("取消", "order-cancel", null, `data-trade="${escapeHtml(order.trade_no)}"`)
|
||
]
|
||
: ["-"]
|
||
)
|
||
]);
|
||
|
||
return [
|
||
wrapTable(["订单号", "用户", "套餐", "金额", "状态", "操作"], rows),
|
||
renderPagination(state.orders.pagination)
|
||
].join("");
|
||
}
|
||
|
||
function renderCouponManage() {
|
||
const rows = state.coupons.map((coupon) => [
|
||
`<code>${coupon.id}</code>`,
|
||
escapeHtml(coupon.name || "-"),
|
||
`<strong>${escapeHtml(coupon.code || "-")}</strong>`,
|
||
escapeHtml(coupon.type === 1 ? "固定金额" : "比例折扣"),
|
||
escapeHtml(coupon.type === 1 ? formatMoney(coupon.value) : `${coupon.value || 0}%`),
|
||
renderActionRow([buttonAction("删除", "coupon-delete", coupon.id)])
|
||
]);
|
||
|
||
return [
|
||
'<div class="toolbar toolbar-spaced"><button class="btn btn-primary" data-action="coupon-add">新增优惠券</button></div>',
|
||
wrapTable(["ID", "名称", "编码", "类型", "面额", "操作"], rows)
|
||
].join("");
|
||
}
|
||
|
||
function renderUserManage() {
|
||
const rows = state.users.list.map((user) => [
|
||
user.parent_id ? renderRelationChip(user.parent_id, user.id) : `<code>${user.id}</code>`,
|
||
[
|
||
`<strong>${escapeHtml(user.email || "-")}</strong>`,
|
||
user.parent_id ? `<div class="subtle-text">${renderRelationChip(user.parent_id, user.id)}</div>` : "",
|
||
user.online_ip ? `<div class="subtle-text">在线 IP: ${escapeHtml(user.online_ip)}</div>` : "",
|
||
`<div class="subtle-text">实名: ${escapeHtml(user.realname_label || user.realname_status || "-")}</div>`,
|
||
user.ipv6_enabled ? `<div class="subtle-text">IPv6: ${renderRelationChip(user.id, user.ipv6_shadow_id)}</div>` : ""
|
||
].join(""),
|
||
renderStatus(user.banned ? "banned" : "active"),
|
||
escapeHtml(`${formatTraffic((user.u || 0) + (user.d || 0))} / ${formatTraffic(user.transfer_enable)}`),
|
||
escapeHtml(formatDate(user.expired_at)),
|
||
renderActionRow([
|
||
buttonAction("编辑", "user-edit", user.id),
|
||
buttonAction("重置流量", "user-reset-traffic", user.id),
|
||
buttonAction(user.banned ? "解封" : "封禁", user.banned ? "user-unban" : "user-ban", user.id)
|
||
])
|
||
]);
|
||
|
||
return [
|
||
wrapTable(["ID", "邮箱", "状态", "流量", "到期时间", "操作"], rows),
|
||
renderPagination(state.users.pagination)
|
||
].join("");
|
||
}
|
||
|
||
function renderTicketManage() {
|
||
const rows = state.tickets.list.map((ticket) => [
|
||
`<code>${ticket.id}</code>`,
|
||
escapeHtml(ticket.user_email || `#${ticket.user_id}`),
|
||
escapeHtml(ticket.subject || "-"),
|
||
renderStatus(ticketStatusText(ticket.status)),
|
||
escapeHtml(formatDate(ticket.updated_at || ticket.created_at)),
|
||
`<code>${ticket.message_count || 0}</code>`
|
||
]);
|
||
|
||
return [
|
||
wrapTable(["ID", "用户", "主题", "状态", "更新时间", "消息数"], rows),
|
||
renderPagination(state.tickets.pagination)
|
||
].join("");
|
||
}
|
||
|
||
function renderRealnameManage() {
|
||
const rows = state.realname.list.map((record) => [
|
||
`<code>${record.id}</code>`,
|
||
escapeHtml(record.email || "-"),
|
||
escapeHtml(record.real_name || "-"),
|
||
`<code>${escapeHtml(record.identity_no_masked || "-")}</code>`,
|
||
renderStatus(record.status_label || record.status || "unverified"),
|
||
escapeHtml(formatDate(record.submitted_at)),
|
||
renderActionRow([
|
||
buttonAction("通过", "realname-review", null, `data-user-id="${record.id}" data-status="approved"`),
|
||
buttonAction("驳回", "realname-review", null, `data-user-id="${record.id}" data-status="rejected"`)
|
||
])
|
||
]);
|
||
|
||
return [
|
||
'<div class="toolbar toolbar-spaced">',
|
||
'<div class="toolbar-group">',
|
||
'<button class="btn btn-primary" data-action="approve-all">全部通过待审</button>',
|
||
'<button class="btn btn-secondary" data-action="sync-all">同步全部状态</button>',
|
||
"</div>",
|
||
"</div>",
|
||
wrapTable(["用户ID", "邮箱", "姓名", "证件号", "状态", "提交时间", "操作"], rows),
|
||
renderPagination(state.realname.pagination)
|
||
].join("");
|
||
}
|
||
|
||
function renderOnlineDevices() {
|
||
const rows = state.devices.list.map((item) => [
|
||
`<code>${item.id}</code>`,
|
||
escapeHtml(item.email || "-"),
|
||
escapeHtml(item.subscription_name || "-"),
|
||
escapeHtml((item.online_devices || []).join(", ") || "-"),
|
||
`<code>${item.online_count || 0}</code>`,
|
||
escapeHtml(item.last_online_text || "-")
|
||
]);
|
||
|
||
return [
|
||
wrapTable(["用户ID", "邮箱", "套餐", "在线 IP", "设备数", "最后在线"], rows),
|
||
renderPagination(state.devices.pagination)
|
||
].join("");
|
||
}
|
||
|
||
function renderIPv6Manage() {
|
||
const rows = state.ipv6.list.map((item) => {
|
||
const isActive = item.is_active || item.status === "active";
|
||
const isDisabled = item.status === "disabled";
|
||
const actions = [];
|
||
if (isActive) {
|
||
actions.push(buttonAction("关闭", "ipv6-disable", null, `data-user-id="${item.id}"`));
|
||
actions.push(buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`));
|
||
} else {
|
||
actions.push(buttonAction("开通", "ipv6-enable-modal", null, `data-user-id="${item.id}" data-email="${escapeHtml(item.email || "")}"`));
|
||
}
|
||
if (isDisabled) {
|
||
actions.unshift(buttonAction("重新开通", "ipv6-enable-modal", null, `data-user-id="${item.id}" data-email="${escapeHtml(item.email || "")}"`));
|
||
}
|
||
return [
|
||
`<code>${item.id}</code>`,
|
||
[
|
||
`<strong>${escapeHtml(item.email || "-")}</strong>`,
|
||
`<div class="subtle-text">${escapeHtml(item.ipv6_email || "-")}</div>`
|
||
].join(""),
|
||
escapeHtml(item.plan_name || "-"),
|
||
renderStatus(item.status_label || item.status || "-"),
|
||
escapeHtml(formatDate(item.updated_at)),
|
||
renderActionRow(actions)
|
||
];
|
||
});
|
||
|
||
return [
|
||
wrapTable(["用户ID", "账号", "IPv6 套餐", "状态", "更新时间", "操作"], rows),
|
||
renderPagination(state.ipv6.pagination)
|
||
].join("");
|
||
}
|
||
|
||
function renderSystemConfig() {
|
||
const sections = ["site", "subscribe", "server", "safe", "invite", "frontend"];
|
||
const activeTab = sections.includes(state.configTab) ? state.configTab : "site";
|
||
const values = (state.config && state.config[activeTab]) || {};
|
||
|
||
return [
|
||
'<div class="tabs-nav card">',
|
||
sections
|
||
.map((section) => {
|
||
const active = section === activeTab ? "btn-primary" : "btn-ghost";
|
||
return `<button class="btn ${active}" data-action="config-tab" data-tab="${section}">${section.toUpperCase()}</button>`;
|
||
})
|
||
.join(""),
|
||
"</div>",
|
||
'<form data-form="config-save" class="card">',
|
||
`<input type="hidden" name="__section" value="${activeTab}" />`,
|
||
'<div class="grid grid-2">',
|
||
Object.keys(values)
|
||
.map((key) => renderConfigField(key, values[key]))
|
||
.join(""),
|
||
"</div>",
|
||
'<div class="toolbar toolbar-spaced"><button class="btn btn-primary" type="submit">保存配置</button></div>',
|
||
"</form>"
|
||
].join("");
|
||
}
|
||
|
||
function renderConfigField(key, value) {
|
||
const label = key.replace(/_/g, " ");
|
||
if (typeof value === "boolean") {
|
||
return [
|
||
'<div class="field">',
|
||
`<label>${escapeHtml(label)}</label>`,
|
||
`<select name="${escapeHtml(key)}"><option value="1" ${value ? "selected" : ""}>开启</option><option value="0" ${value ? "" : "selected"}>关闭</option></select>`,
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
return [
|
||
'<div class="field">',
|
||
`<label>${escapeHtml(label)}</label>`,
|
||
`<input name="${escapeHtml(key)}" value="${escapeHtml(value == null ? "" : String(value))}" />`,
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
function renderModalContent() {
|
||
const modal = state.modal;
|
||
const body =
|
||
modal.type === "plan"
|
||
? renderPlanForm(modal.data)
|
||
: modal.type === "coupon"
|
||
? renderCouponForm(modal.data)
|
||
: modal.type === "user"
|
||
? renderUserForm(modal.data)
|
||
: modal.type === "node"
|
||
? renderNodeForm(modal.data)
|
||
: modal.type === "group"
|
||
? renderGroupForm(modal.data)
|
||
: modal.type === "route"
|
||
? renderRouteForm(modal.data)
|
||
: modal.type === "ipv6-enable"
|
||
? renderIPv6EnableForm(modal.data)
|
||
: "";
|
||
|
||
return [
|
||
'<div class="modal-card">',
|
||
'<div class="card modal-card-inner">',
|
||
'<button class="btn btn-close" data-action="modal-close">×</button>',
|
||
body,
|
||
"</div>",
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
function renderPlanForm(plan) {
|
||
return [
|
||
`<h2>${plan.id ? "编辑套餐" : "新建套餐"}</h2>`,
|
||
'<form data-form="plan-save">',
|
||
hiddenField("id", plan.id || ""),
|
||
inputField("套餐名称", "name", plan.name || "", "text", true),
|
||
selectField("权限组", "group_id", state.groups, plan.group_id, false),
|
||
inputField("流量额度", "transfer_enable", plan.transfer_enable || 0, "number", false),
|
||
inputField("速度限制 Mbps", "speed_limit", plan.speed_limit || 0, "number", false),
|
||
textareaField("价格 JSON", "prices", plan.prices || "{}"),
|
||
booleanField("是否展示", "show", plan.show !== false),
|
||
submitRow(plan.id ? "保存套餐" : "创建套餐"),
|
||
"</form>"
|
||
].join("");
|
||
}
|
||
|
||
function renderCouponForm(coupon) {
|
||
return [
|
||
`<h2>${coupon.id ? "编辑优惠券" : "新建优惠券"}</h2>`,
|
||
'<form data-form="coupon-save">',
|
||
hiddenField("id", coupon.id || ""),
|
||
inputField("名称", "name", coupon.name || "", "text", true),
|
||
inputField("编码", "code", coupon.code || "", "text", true),
|
||
selectSimpleField("类型", "type", [
|
||
{ value: 1, label: "固定金额" },
|
||
{ value: 2, label: "比例折扣" }
|
||
], coupon.type || 1),
|
||
inputField("数值", "value", coupon.value || 0, "number", true),
|
||
booleanField("是否展示", "show", coupon.show !== false),
|
||
submitRow(coupon.id ? "保存优惠券" : "创建优惠券"),
|
||
"</form>"
|
||
].join("");
|
||
}
|
||
|
||
function renderUserForm(user) {
|
||
return [
|
||
`<h2>编辑用户 #${escapeHtml(user.id || "")}</h2>`,
|
||
'<form data-form="user-save">',
|
||
hiddenField("id", user.id || ""),
|
||
inputField("邮箱", "email", user.email || "", "email", true),
|
||
inputField("新密码", "password", "", "password", false),
|
||
inputField("余额", "balance", user.balance || 0, "number", false),
|
||
selectField("套餐", "plan_id", state.plans, user.plan_id, true),
|
||
inputField("设备限制", "device_limit", user.device_limit || 0, "number", false),
|
||
inputField("速度限制 Mbps", "speed_limit", user.speed_limit || 0, "number", false),
|
||
textareaField("备注", "remarks", user.remarks || ""),
|
||
datetimeField("到期时间", "expired_at", user.expired_at),
|
||
submitRow("保存用户"),
|
||
"</form>"
|
||
].join("");
|
||
}
|
||
|
||
function renderNodeForm(node) {
|
||
return [
|
||
`<h2>${node.id ? "编辑节点" : "新增节点"}</h2>`,
|
||
'<form data-form="node-save">',
|
||
hiddenField("id", node.id || ""),
|
||
inputField("节点名称", "name", node.name || "", "text", true),
|
||
inputField("节点类型", "type", node.type || "shadowsocks", "text", true),
|
||
inputField("主机地址", "host", node.host || "", "text", true),
|
||
inputField("对外端口", "port", node.port || "", "text", true),
|
||
inputField("服务端口", "server_port", node.server_port || 443, "number", true),
|
||
inputField("倍率", "rate", node.rate || 1, "number", true, 'step="0.1"'),
|
||
booleanField("是否展示", "show", node.show !== false),
|
||
submitRow(node.id ? "保存节点" : "创建节点"),
|
||
"</form>"
|
||
].join("");
|
||
}
|
||
|
||
function renderGroupForm(group) {
|
||
return [
|
||
`<h2>${group.id ? "编辑权限组" : "新增权限组"}</h2>`,
|
||
'<form data-form="group-save">',
|
||
hiddenField("id", group.id || ""),
|
||
inputField("权限组名称", "name", group.name || "", "text", true),
|
||
submitRow(group.id ? "保存权限组" : "创建权限组"),
|
||
"</form>"
|
||
].join("");
|
||
}
|
||
|
||
function renderRouteForm(route) {
|
||
return [
|
||
`<h2>${route.id ? "编辑路由规则" : "新增路由规则"}</h2>`,
|
||
'<form data-form="route-save">',
|
||
hiddenField("id", route.id || ""),
|
||
inputField("备注", "remarks", route.remarks || "", "text", true),
|
||
textareaField("匹配规则", "match", Array.isArray(route.match) ? route.match.join("\n") : ""),
|
||
selectSimpleField("动作", "action", [
|
||
{ value: "direct", label: "direct" },
|
||
{ value: "proxy", label: "proxy" },
|
||
{ value: "block", label: "block" },
|
||
{ value: "dns", label: "dns" }
|
||
], route.action || "direct"),
|
||
inputField("动作值", "action_value", route.action_value || "", "text", false),
|
||
submitRow(route.id ? "保存规则" : "创建规则"),
|
||
"</form>"
|
||
].join("");
|
||
}
|
||
|
||
function renderNotice() {
|
||
return `<div class="notice notice-${escapeHtml(state.messageType)}">${escapeHtml(state.message)}</div>`;
|
||
}
|
||
|
||
function wrapTable(headers, rows) {
|
||
return `<div class="table-wrap">${renderTable(headers, rows)}</div>`;
|
||
}
|
||
|
||
function renderTable(headers, rows) {
|
||
if (!rows || !rows.length) {
|
||
return '<div class="empty-state">暂无数据</div>';
|
||
}
|
||
|
||
return [
|
||
"<table>",
|
||
"<thead><tr>",
|
||
headers.map((header) => `<th>${escapeHtml(header)}</th>`).join(""),
|
||
"</tr></thead>",
|
||
"<tbody>",
|
||
rows
|
||
.map((row) => {
|
||
const isObjectRow = !Array.isArray(row);
|
||
const cells = isObjectRow ? row.cells : row;
|
||
const className = isObjectRow ? row.className || "" : "";
|
||
return `<tr class="${className}">${cells.map((cell) => `<td>${cell}</td>`).join("")}</tr>`;
|
||
})
|
||
.join(""),
|
||
"</tbody>",
|
||
"</table>"
|
||
].join("");
|
||
}
|
||
|
||
function renderPagination(pagination) {
|
||
if (!pagination || pagination.last_page <= 1) {
|
||
return "";
|
||
}
|
||
|
||
const buttons = [];
|
||
for (let page = 1; page <= pagination.last_page; page += 1) {
|
||
buttons.push(
|
||
`<button class="page-btn ${pagination.current === page ? "active" : ""}" data-action="page" data-page="${page}">${page}</button>`
|
||
);
|
||
}
|
||
|
||
return `<div class="pagination-shell">${buttons.join("")}</div>`;
|
||
}
|
||
|
||
function statCard(title, value, hint) {
|
||
return [
|
||
'<article class="card stat-card">',
|
||
`<span>${escapeHtml(title)}</span>`,
|
||
`<strong>${escapeHtml(value)}</strong>`,
|
||
`<p>${escapeHtml(hint || "")}</p>`,
|
||
"</article>"
|
||
].join("");
|
||
}
|
||
|
||
function summaryItem(label, value) {
|
||
return `<div class="summary-item"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
||
}
|
||
|
||
function buttonAction(label, action, id, extraAttrs) {
|
||
const idAttr = id == null ? "" : `data-id="${id}"`;
|
||
return `<button class="btn btn-ghost" data-action="${action}" ${idAttr} ${extraAttrs || ""}>${escapeHtml(label)}</button>`;
|
||
}
|
||
|
||
function renderActionRow(items) {
|
||
return `<div class="row-actions">${items.join("")}</div>`;
|
||
}
|
||
|
||
function renderRelationChip(fromId, toId) {
|
||
return `<span class="relation-chip"><code>${escapeHtml(fromId)}</code><span class="relation-arrow">→</span><code>${escapeHtml(toId)}</code></span>`;
|
||
}
|
||
|
||
function hiddenField(name, value) {
|
||
return `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(String(value))}" />`;
|
||
}
|
||
|
||
function inputField(label, name, value, type, required, extraAttrs) {
|
||
return [
|
||
'<div class="field">',
|
||
`<label>${escapeHtml(label)}</label>`,
|
||
`<input type="${type}" name="${escapeHtml(name)}" value="${escapeHtml(String(value == null ? "" : value))}" ${required ? "required" : ""} ${extraAttrs || ""} />`,
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
function textareaField(label, name, value) {
|
||
return [
|
||
'<div class="field">',
|
||
`<label>${escapeHtml(label)}</label>`,
|
||
`<textarea name="${escapeHtml(name)}">${escapeHtml(String(value == null ? "" : value))}</textarea>`,
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
function booleanField(label, name, value) {
|
||
return selectSimpleField(label, name, [
|
||
{ value: 1, label: "开启" },
|
||
{ value: 0, label: "关闭" }
|
||
], value ? 1 : 0);
|
||
}
|
||
|
||
function datetimeField(label, name, value) {
|
||
const formatted = value ? new Date(value * 1000).toISOString().slice(0, 16) : "";
|
||
return inputField(label, name, formatted, "datetime-local", false);
|
||
}
|
||
|
||
function selectField(label, name, items, currentValue, allowEmpty) {
|
||
const options = [];
|
||
if (allowEmpty) {
|
||
options.push('<option value="0">无</option>');
|
||
}
|
||
items.forEach((item) => {
|
||
options.push(
|
||
`<option value="${item.id}" ${Number(currentValue) === Number(item.id) ? "selected" : ""}>${escapeHtml(item.name)}</option>`
|
||
);
|
||
});
|
||
return `<div class="field"><label>${escapeHtml(label)}</label><select name="${escapeHtml(name)}">${options.join("")}</select></div>`;
|
||
}
|
||
|
||
function selectSimpleField(label, name, items, currentValue) {
|
||
return `<div class="field"><label>${escapeHtml(label)}</label><select name="${escapeHtml(name)}">${items
|
||
.map((item) => `<option value="${escapeHtml(String(item.value))}" ${String(item.value) === String(currentValue) ? "selected" : ""}>${escapeHtml(item.label)}</option>`)
|
||
.join("")}</select></div>`;
|
||
}
|
||
|
||
function submitRow(label) {
|
||
return `<div class="toolbar toolbar-spaced"><button class="btn btn-primary" type="submit">${escapeHtml(label)}</button></div>`;
|
||
}
|
||
|
||
function renderIPv6EnableForm(data) {
|
||
return [
|
||
`<h2>开通 IPv6 子账号</h2>`,
|
||
`<p>用户: <strong>${escapeHtml(data.email || data.userId)}</strong></p>`,
|
||
'<form data-form="ipv6-enable-save">',
|
||
hiddenField("user_id", data.userId),
|
||
selectField("IPv6 套餐", "plan_id", state.plans, 0, true),
|
||
submitRow("确认开通"),
|
||
"</form>"
|
||
].join("");
|
||
}
|
||
|
||
function setBusy(value) {
|
||
state.busy = value;
|
||
render();
|
||
}
|
||
|
||
function show(message, type) {
|
||
state.message = message;
|
||
state.messageType = type || "info";
|
||
render();
|
||
window.clearTimeout(show._timer);
|
||
show._timer = window.setTimeout(function () {
|
||
state.message = "";
|
||
render();
|
||
}, 3000);
|
||
}
|
||
|
||
async function adminPost(url, body) {
|
||
try {
|
||
const response = await request(url, { method: "POST", body });
|
||
show("操作成功", "success");
|
||
return response;
|
||
} catch (error) {
|
||
show(error.message || "操作失败", "error");
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function request(url, options) {
|
||
const opt = options || {};
|
||
const headers = { "Content-Type": "application/json" };
|
||
if (opt.auth !== false && state.token) {
|
||
headers.Authorization = state.token;
|
||
}
|
||
|
||
const response = await fetch(url, {
|
||
method: opt.method || "GET",
|
||
headers,
|
||
body: opt.body ? JSON.stringify(opt.body) : undefined
|
||
});
|
||
const payload = await response.json().catch(() => null);
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
clearSession();
|
||
render();
|
||
}
|
||
throw new Error((payload && (payload.message || payload.msg)) || "请求失败");
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
function normalizeListPayload(payload, listKey) {
|
||
const key = listKey || "list";
|
||
return {
|
||
list: toArray(payload, key),
|
||
pagination: payload && payload.pagination ? payload.pagination : null
|
||
};
|
||
}
|
||
|
||
function unwrap(payload) {
|
||
return payload && typeof payload.data !== "undefined" ? payload.data : payload;
|
||
}
|
||
|
||
function toArray(value, key) {
|
||
if (Array.isArray(value)) {
|
||
return value;
|
||
}
|
||
if (!value || typeof value !== "object") {
|
||
return [];
|
||
}
|
||
if (Array.isArray(value[key || "data"])) {
|
||
return value[key || "data"];
|
||
}
|
||
if (Array.isArray(value.list)) {
|
||
return value.list;
|
||
}
|
||
if (Array.isArray(value.data)) {
|
||
return value.data;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function serializeForm(form) {
|
||
const values = {};
|
||
const section = form.querySelector('input[name="__section"]');
|
||
const formData = new window.FormData(form);
|
||
|
||
formData.forEach((rawValue, key) => {
|
||
if (key === "__section") {
|
||
return;
|
||
}
|
||
|
||
if (key === "show" || key === "renew" || key === "sell") {
|
||
values[key] = String(rawValue) === "1";
|
||
return;
|
||
}
|
||
|
||
if (key === "expired_at") {
|
||
values[key] = rawValue ? Math.floor(new Date(rawValue).getTime() / 1000) : null;
|
||
return;
|
||
}
|
||
|
||
if (key === "match") {
|
||
values[key] = String(rawValue)
|
||
.split(/\r?\n/)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
return;
|
||
}
|
||
|
||
if (["id", "group_id", "transfer_enable", "speed_limit", "balance", "plan_id", "device_limit", "server_port", "value", "user_id"].includes(key)) {
|
||
values[key] = rawValue === "" ? null : Number(rawValue);
|
||
return;
|
||
}
|
||
|
||
values[key] = rawValue;
|
||
});
|
||
|
||
if (section) {
|
||
return values;
|
||
}
|
||
|
||
return values;
|
||
}
|
||
|
||
function getCurrentPage() {
|
||
return state.pagination[state.route] || 1;
|
||
}
|
||
|
||
function setCurrentPage(page) {
|
||
state.pagination[state.route] = page > 0 ? page : 1;
|
||
}
|
||
|
||
function readToken() {
|
||
return window.localStorage.getItem("__gopanel_admin_auth__") || "";
|
||
}
|
||
|
||
function saveToken(token) {
|
||
window.localStorage.setItem("__gopanel_admin_auth__", /^Bearer /.test(token) ? token : `Bearer ${token}`);
|
||
}
|
||
|
||
function clearSession() {
|
||
state.token = "";
|
||
state.user = null;
|
||
state.modal = null;
|
||
window.localStorage.removeItem("__gopanel_admin_auth__");
|
||
}
|
||
|
||
function readRoute() {
|
||
return (window.location.hash || "#overview").slice(1);
|
||
}
|
||
|
||
function normalizeRoute(route) {
|
||
return ROUTE_META[route] ? route : "overview";
|
||
}
|
||
|
||
function getSecurePath() {
|
||
if (state.config && state.config.safe && state.config.safe.secure_path) {
|
||
return String(state.config.safe.secure_path).replace(/^\//, "");
|
||
}
|
||
return String(cfg.securePath || "admin").replace(/^\//, "");
|
||
}
|
||
|
||
function formatTraffic(bytes) {
|
||
const value = Number(bytes || 0);
|
||
if (!value) {
|
||
return "0 B";
|
||
}
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let index = 0;
|
||
let size = value;
|
||
while (size >= 1024 && index < units.length - 1) {
|
||
size /= 1024;
|
||
index += 1;
|
||
}
|
||
return `${size.toFixed(size >= 100 ? 0 : 2)} ${units[index]}`;
|
||
}
|
||
|
||
function formatMoney(amount) {
|
||
return `¥${((Number(amount) || 0) / 100).toFixed(2)}`;
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) {
|
||
return "-";
|
||
}
|
||
const date = new Date(typeof value === "number" ? value * 1000 : value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return String(value);
|
||
}
|
||
return date.toLocaleString();
|
||
}
|
||
|
||
function formatSignedPercent(value) {
|
||
const num = Number(value || 0);
|
||
return `${num >= 0 ? "+" : ""}${num.toFixed(2)}%`;
|
||
}
|
||
|
||
function orderStatusText(status) {
|
||
if (status === 0) return "pending";
|
||
if (status === 3) return "paid";
|
||
if (status === 2) return "cancelled";
|
||
return String(status);
|
||
}
|
||
|
||
function ticketStatusText(status) {
|
||
if (status === 0) return "pending";
|
||
if (status === 1) return "answered";
|
||
if (status === 2) return "closed";
|
||
return String(status);
|
||
}
|
||
|
||
function renderStatus(status) {
|
||
const text = String(status || "-");
|
||
const normalized = text.toLowerCase();
|
||
let type = "danger";
|
||
if (/(ok|active|visible|approved|paid|answered|online|enabled)/.test(normalized)) {
|
||
type = "ok";
|
||
} else if (/(pending|warn|unverified|eligible|ready)/.test(normalized)) {
|
||
type = "warn";
|
||
} else if (/(disabled)/.test(normalized)) {
|
||
type = "warn";
|
||
}
|
||
return `<span class="status-pill status-${type}">${escapeHtml(text)}</span>`;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value == null ? "" : value)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function getRouteTitle(route) {
|
||
return route.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||
}
|
||
})();
|