Files
SingBox-Gopanel/frontend/admin/app.js
CN-JS-HuiBai 98379b21f4
Some checks failed
build / build (api, amd64, linux) (push) Failing after -50s
build / build (api, arm64, linux) (push) Failing after -52s
build / build (api.exe, amd64, windows) (push) Failing after -51s
修复节点无法编辑的错误
2026-04-18 10:31:31 +08:00

1572 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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">&rarr;</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">&rarr;</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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function getRouteTitle(route) {
return route.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
})();