Files
SingBox-Gopanel/frontend/admin/app.js
CN-JS-HuiBai 06da23fbbc
All checks were successful
build / build (api, amd64, linux) (push) Successful in -45s
build / build (api, arm64, linux) (push) Successful in -48s
build / build (api.exe, amd64, windows) (push) Successful in -47s
进一步补充后端API
2026-04-17 10:46:15 +08:00

869 lines
40 KiB
JavaScript

(function () {
"use strict";
var cfg = window.ADMIN_APP_CONFIG || {};
var api = cfg.api || {};
var root = document.getElementById("admin-app");
if (!root) return;
var state = {
token: readToken(),
user: null,
route: normalizeRoute(readRoute()),
message: "",
messageType: "",
config: null,
system: null,
realname: null,
devices: null,
nodes: null,
groups: null,
routes: null,
plans: null,
orders: null,
coupons: null,
users: null,
tickets: null,
currentTicket: null,
dashboard: null,
busy: false,
modal: null, // { type, data }
configTab: "site"
};
boot();
async function boot() {
window.addEventListener("hashchange", function () {
state.route = normalizeRoute(readRoute());
state.modal = null;
render();
hydrateRoute();
});
root.addEventListener("click", onClick);
root.addEventListener("submit", onSubmit);
if (state.token) {
await loadBootstrap();
}
render();
hydrateRoute();
}
async function loadBootstrap() {
try {
state.busy = true;
var loginCheck = unwrap(await request("/api/v1/user/checkLogin", { method: "GET" }));
if (!loginCheck || !loginCheck.is_login || !loginCheck.is_admin) {
clearSession();
return;
}
state.user = loginCheck;
var results = await Promise.all([
request(api.adminConfig, { method: "GET" }),
request(api.systemStatus, { method: "GET" })
]);
state.config = unwrap(results[0]) || {};
state.system = unwrap(results[1]) || {};
} catch (error) {
clearSession();
show(error.message || "Failed to load admin data.", "error");
} finally {
state.busy = false;
}
}
async function hydrateRoute() {
if (!state.user) return;
try {
state.busy = true;
render();
var path = getSecurePath();
if (state.route === "realname") {
state.realname = unwrap(await request(api.realnameBase + "/records?per_page=50")) || {};
} else if (state.route === "user-online-devices") {
state.devices = unwrap(await request(api.onlineDevices + "?per_page=50")) || {};
} else if (state.route === "node-manage") {
state.nodes = toArray(unwrap(await request(api.serverNodes)));
state.groups = toArray(unwrap(await request(api.serverGroups)));
} else if (state.route === "node-group") {
state.groups = toArray(unwrap(await request(api.serverGroups)));
} else if (state.route === "node-route") {
state.routes = toArray(unwrap(await request(api.serverRoutes)));
} else if (state.route === "plan-manage") {
state.plans = toArray(unwrap(await request(api.plans)));
state.groups = toArray(unwrap(await request(api.serverGroups)));
} else if (state.route === "order-manage") {
state.orders = unwrap(await request(api.orders + "?per_page=50")) || {};
} else if (state.route === "coupon-manage") {
state.coupons = toArray(unwrap(await request(api.coupons)));
} else if (state.route === "user-manage") {
state.users = unwrap(await request(api.users + "?per_page=50")) || {};
state.groups = toArray(unwrap(await request(api.serverGroups)));
state.plans = toArray(unwrap(await request(api.plans)));
} else if (state.route === "ticket-manage") {
state.tickets = unwrap(await request(api.tickets + "?per_page=50")) || {};
} else if (state.route === "dashboard-node") {
state.dashboard = unwrap(await request(api.dashboardSummary));
state.nodes = toArray(unwrap(await request(api.serverNodes)));
} else if (state.route === "system-config") {
state.config = unwrap(await request(api.adminConfig));
} else if (state.route === "overview") {
state.dashboard = unwrap(await request(api.dashboardSummary));
}
} catch (error) {
show(error.message || "Failed to load page data.", "error");
} finally {
state.busy = false;
render();
}
}
function onClick(event) {
var actionEl = event.target.closest("[data-action]");
if (!actionEl) return;
var action = actionEl.getAttribute("data-action");
if (action === "logout") { clearSession(); render(); return; }
if (action === "nav") { window.location.hash = actionEl.getAttribute("data-route") || "overview"; return; }
if (action === "refresh") { refreshAll(); return; }
if (action === "modal-close") { state.modal = null; render(); return; }
if (action === "config-tab") { state.configTab = actionEl.getAttribute("data-tab"); render(); return; }
// Handlers
if (action === "plan-add") { state.modal = { type: "plan", data: {} }; render(); return; }
if (action === "plan-edit") {
var plan = state.plans.find(p => p.id == actionEl.getAttribute("data-id"));
state.modal = { type: "plan", data: plan }; render(); return;
}
if (action === "plan-delete") {
if (!confirm("Are you sure?")) return;
adminPost(api.adminBase + "/plan/drop", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute);
return;
}
if (action === "order-paid") {
adminPost(api.adminBase + "/order/paid", { trade_no: actionEl.getAttribute("data-trade") }).then(hydrateRoute);
return;
}
if (action === "order-cancel") {
adminPost(api.adminBase + "/order/cancel", { trade_no: actionEl.getAttribute("data-trade") }).then(hydrateRoute);
return;
}
if (action === "coupon-add") { state.modal = { type: "coupon", data: {} }; render(); return; }
if (action === "coupon-delete") {
if (!confirm("Are you sure?")) return;
adminPost(api.adminBase + "/coupon/drop", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute);
return;
}
if (action === "user-edit") {
var user = state.users.list.find(u => u.id == actionEl.getAttribute("data-id"));
state.modal = { type: "user", data: user }; render(); return;
}
if (action === "user-ban") {
adminPost(api.adminBase + "/user/ban", { id: parseInt(actionEl.getAttribute("data-id")), banned: true }).then(hydrateRoute);
return;
}
if (action === "user-unban") {
adminPost(api.adminBase + "/user/ban", { id: parseInt(actionEl.getAttribute("data-id")), banned: false }).then(hydrateRoute);
return;
}
if (action === "user-reset-traffic") {
adminPost(api.adminBase + "/user/resetTraffic", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute);
return;
}
if (action === "node-add") { state.modal = { type: "node", data: {} }; render(); return; }
if (action === "node-edit") {
var node = state.nodes.find(n => n.id == actionEl.getAttribute("data-id"));
state.modal = { type: "node", data: node }; render(); return;
}
if (action === "node-copy") {
adminPost(api.adminBase + "/server/manage/copy", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute);
return;
}
if (action === "node-delete") {
if (!confirm("Are you sure?")) return;
adminPost(api.adminBase + "/server/manage/drop", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute);
return;
}
if (action === "group-add") { state.modal = { type: "group", data: {} }; render(); return; }
if (action === "group-edit") {
var group = state.groups.find(g => g.id == actionEl.getAttribute("data-id"));
state.modal = { type: "group", data: group }; render(); return;
}
if (action === "group-delete") {
if (!confirm("Are you sure?")) return;
adminPost(api.adminBase + "/server/group/drop", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute);
return;
}
if (action === "route-add") { state.modal = { type: "route", data: {} }; render(); return; }
if (action === "route-edit") {
var route = state.routes.find(r => r.id == actionEl.getAttribute("data-id"));
state.modal = { type: "route", data: route }; render(); return;
}
if (action === "route-delete") {
if (!confirm("Are you sure?")) return;
adminPost(api.adminBase + "/server/route/drop", { id: parseInt(actionEl.getAttribute("data-id")) }).then(hydrateRoute);
return;
}
// Previous handlers
if (action === "approve-all") { adminPost(api.realnameBase + "/approve-all", {}).then(hydrateRoute); return; }
if (action === "sync-all") { adminPost(api.realnameBase + "/sync-all", {}).then(hydrateRoute); return; }
if (action === "clear-cache") { adminPost(api.realnameBase + "/clear-cache", {}); return; }
}
function onSubmit(event) {
var form = event.target;
var action = form.getAttribute("data-form");
if (!action) return;
event.preventDefault();
if (action === "login") {
request("/api/v1/passport/auth/login", { method: "POST", auth: false, body: serializeForm(form) }).then(function (res) {
var payload = unwrap(res);
if (!payload || !payload.auth_data || !payload.is_admin) throw new Error("Incorrect permissions.");
saveToken(payload.auth_data);
state.token = readToken();
return loadBootstrap();
}).then(function () {
show("Login successful.", "success");
render(); hydrateRoute();
}).catch(function (err) { show(err.message || "Login failed.", "error"); render(); });
return;
}
// Admin Forms
if (action === "plan-save") {
adminPost(api.adminBase + "/plan/save", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); });
return;
}
if (action === "coupon-save") {
adminPost(api.adminBase + "/coupon/save", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); });
return;
}
if (action === "user-save") {
adminPost(api.adminBase + "/user/update", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); });
return;
}
if (action === "config-save") {
adminPost(api.adminBase + "/config/save", serializeForm(form)).then(() => { hydrateRoute(); });
return;
}
if (action === "node-save") {
adminPost(api.adminBase + "/server/manage/save", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); });
return;
}
if (action === "group-save") {
adminPost(api.adminBase + "/server/group/save", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); });
return;
}
if (action === "route-save") {
adminPost(api.adminBase + "/server/route/save", serializeForm(form)).then(() => { state.modal = null; hydrateRoute(); });
return;
}
}
function render() {
root.innerHTML = state.user ? renderDashboard() : renderLogin();
if (state.modal) {
var modalShell = document.createElement("div");
modalShell.className = "modal-overlay fade-in";
modalShell.innerHTML = renderModalContent();
root.appendChild(modalShell);
}
if (state.busy) {
var overlay = document.createElement("div");
overlay.style = "position:fixed; inset:0; background:rgba(0,0,0,0.2); z-index:9999; cursor:wait;";
root.appendChild(overlay);
}
}
function renderModalContent() {
var m = state.modal;
var html = '<div class="glass-card modal-content"><button class="btn-close" data-action="modal-close">&times;</button>';
if (m.type === "plan") html += renderPlanForm(m.data);
if (m.type === "user") html += renderUserForm(m.data);
if (m.type === "coupon") html += renderCouponForm(m.data);
if (m.type === "node") html += renderNodeForm(m.data);
if (m.type === "group") html += renderGroupForm(m.data);
if (m.type === "route") html += renderRouteForm(m.data);
html += '</div>';
return html;
}
function renderPlanForm(d) {
return [
'<h2>' + (d.id ? "Edit Plan" : "Create Plan") + '</h2>',
'<form data-form="plan-save">',
'<input type="hidden" name="id" value="' + (d.id || "") + '" />',
'<div class="field"><label>Plan Name</label><input name="name" value="' + (d.name || "") + '" required /></div>',
'<div class="field"><label>Permission Group</label><select name="group_id">' + state.groups.map(g => '<option value="'+g.id+'" '+(d.group_id==g.id?"selected":"")+'>'+g.name+'</option>').join("") + '</select></div>',
'<div class="field"><label>Traffic Limit (GB)</label><input type="number" name="transfer_enable" value="' + (d.transfer_enable || 0) + '" /></div>',
'<div class="field"><label>Speed Limit (Mbps)</label><input type="number" name="speed_limit" value="' + (d.speed_limit || 0) + '" /></div>',
'<div class="field"><label>Pricing JSON</label><textarea name="prices" style="height:100px;">' + (d.prices || "{}") + '</textarea></div>',
'<div class="field"><label>Visibility</label><select name="show"><option value="1">Visible</option><option value="0" '+(d.show? "":"selected")+'>Hidden</option></select></div>',
'<button class="btn btn-primary" type="submit">Save Changes</button>',
'</form>'
].join("");
}
function renderUserForm(d) {
return [
'<h2>Edit User</h2>',
'<form data-form="user-save">',
'<input type="hidden" name="id" value="' + d.id + '" />',
'<div class="field"><label>Email Address</label><input name="email" value="' + (d.email || "") + '" required /></div>',
'<div class="field"><label>New Password (Optional)</label><input type="password" name="password" placeholder="Leave empty to keep current" /></div>',
'<div class="field"><label>Balance (CNY Cents)</label><input type="number" name="balance" value="' + (d.balance || 0) + '" /></div>',
'<div class="field"><label>Subscription</label><select name="plan_id"><option value="0">None</option>' + state.plans.map(p => '<option value="'+p.id+'" '+(d.plan_id==p.id?"selected":"")+'>'+p.name+'</option>').join("") + '</select></div>',
'<div class="field"><label>Expiry Date</label><input type="datetime-local" name="expired_at" value="' + (d.expired_at ? new Date(d.expired_at*1000).toISOString().slice(0,16) : "") + '" /></div>',
'<button class="btn btn-primary" type="submit">Update User</button>',
'</form>'
].join("");
}
function renderCouponForm(d) {
return [
'<h2>Create Coupon</h2>',
'<form data-form="coupon-save">',
'<div class="field"><label>Name</label><input name="name" required /></div>',
'<div class="field"><label>Code</label><input name="code" required /></div>',
'<div class="field"><label>Type</label><select name="type"><option value="1">Fixed Amount</option><option value="2">Percentage</option></select></div>',
'<div class="field"><label>Value</label><input type="number" name="value" required /></div>',
'<button class="btn btn-primary" type="submit">Save Coupon</button>',
'</form>'
].join("");
}
function renderLogin() {
return [
'<div class="login-shell">',
'<section class="glass-card card modal-card fade-in">',
'<div style="text-align:center; margin-bottom:32px;">',
'<span class="topbar-eyebrow">SingBox Gopanel</span>',
'<h1 style="font-size:36px; margin:0;">Welcome Back</h1>',
'<p style="color:var(--text-dim); margin-top:8px;">Sign in to manage your high-performance network.</p>',
'</div>',
state.message ? renderNotice() : "",
'<form data-form="login">',
'<div class="field"><label>Email Address</label><input type="email" name="email" placeholder="admin@example.com" required autocomplete="email" /></div>',
'<div class="field"><label>Secure Password</label><input type="password" name="password" placeholder="••••••••" required autocomplete="current-password" /></div>',
'<button class="btn btn-primary" style="width:100%; height:52px; font-size:16px; margin-top:12px;" type="submit">Unlock Console</button>',
'</form>',
'</section>',
'</div>'
].join("");
}
function renderDashboard() {
return [
'<div class="admin-shell fade-in">',
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-brand">',
'<span class="sidebar-brand-mark">Nebula</span>',
'<strong>' + escapeHtml(cfg.title || "Admin") + '</strong>',
'<span>/' + escapeHtml(getSecurePath()) + ' Console</span>',
'</div>',
'<div class="sidebar-nav-label">General</div>',
'<nav class="sidebar-nav">',
navItem("overview", "Overview", "Main statistics"),
navItem("dashboard-node", "Nodes Status", "Real-time metrics"),
'</nav>',
'<div class="sidebar-nav-label">Infrastructure</div>',
'<nav class="sidebar-nav">',
navItem("node-manage", "Server Nodes", "Core infrastructure"),
navItem("node-group", "Permission Groups", "Access control"),
navItem("node-route", "Traffic Routing", "Advanced rules"),
'</nav>',
'<div class="sidebar-nav-label">Financials</div>',
'<nav class="sidebar-nav">',
navItem("plan-manage", "Packages", "Sales and traffic"),
navItem("order-manage", "Transactions", "Payment history"),
navItem("coupon-manage", "Marketing", "Coupons & gifts"),
'</nav>',
'<div class="sidebar-nav-label">Operations</div>',
'<nav class="sidebar-nav">',
navItem("user-manage", "User Base", "Accounts & traffic"),
navItem("user-online-devices", "Live Sessions", "Real-time monitoring"),
navItem("ticket-manage", "Service Desk", "Support tickets"),
navItem("realname", "KYC Workflow", "Identity verification"),
'</nav>',
'<div class="sidebar-nav-label">Platform</div>',
'<nav class="sidebar-nav">',
navItem("system-config", "Settings", "System core"),
'</nav>',
'<div style="margin-top:auto;" class="sidebar-meta">',
'<span class="sidebar-nav-label">Session</span>',
'<div style="padding:0 12px; font-size:13px; color:var(--text-dim);">' + escapeHtml(state.user.email) + '</div>',
'<div class="toolbar" style="padding:12px;"><button class="btn btn-ghost" style="width:100%; border:1px solid var(--border);" data-action="logout">Logout</button></div>',
'</div>',
'</aside>'
].join("");
}
function renderTopbar() {
return [
'<header class="topbar glass-card">',
'<div class="topbar-copy">',
'<span class="topbar-eyebrow">Workspace / ' + escapeHtml(getRouteTitle(state.route)) + '</span>',
'<h1>' + escapeHtml(getRouteTitle(state.route)) + '</h1>',
'<p>' + escapeHtml(getRouteDescription(state.route)) + '</p>',
'<div class="topbar-meta">',
'<span class="topbar-chip">PATH: /' + escapeHtml(getSecurePath()) + '</span>',
'<span class="topbar-chip">' + formatDate(state.system && state.system.server_time) + '</span>',
'</div>',
'</div>',
'<div class="toolbar">',
'<button class="btn btn-secondary" data-action="refresh">Sync State</button>',
'</div>',
'</header>'
].join("");
}
function renderMainContent() {
var route = state.route;
if (route === "dashboard-node") return renderDashboardNode();
if (route === "node-manage") return renderNodeManage();
if (route === "node-group") return renderNodeGroup();
if (route === "node-route") return renderNodeRoute();
if (route === "plan-manage") return renderPlanManage();
if (route === "order-manage") return renderOrderManage();
if (route === "coupon-manage") return renderCouponManage();
if (route === "user-manage") return renderUserManage();
if (route === "ticket-manage") return renderTicketManage();
if (route === "system-config") return renderSystemConfig();
if (route === "realname") return renderRealName();
if (route === "user-online-devices") return renderOnlineDevices();
return renderOverview();
}
function renderOverview() {
var dash = state.dashboard || {};
return [
'<div class="grid grid-3">',
statCard("Revenue", "¥" + ((dash.paid_order_amount || 0) / 100).toFixed(2), "Total paid volume"),
statCard("Active Users", String(dash.active_users_30d || 0), "Online in last 30d"),
statCard("Support", String(dash.pending_tickets || 0), "Unresolved tickets"),
'</div>',
'<section class="card glass-card" style="margin-top:24px;">',
'<h2>Integrated System State</h2>',
'<p style="color:var(--text-dim); margin-bottom:24px;">All management logic is now fully functional and integrated with the Go backend.</p>',
'<div class="grid grid-3">',
'<div class="hero-metric" style="background:rgba(255,255,255,0.03); padding:20px; border-radius:16px; border:1px solid var(--border);">',
'<span style="font-size:12px; text-transform:uppercase; color:var(--text-faint);">KYC Logic</span>',
'<div style="margin-top:8px; font-weight:700; color:var(--success);">Core Integrated</div>',
'</div>',
'<div class="hero-metric" style="background:rgba(255,255,255,0.03); padding:20px; border-radius:16px; border:1px solid var(--border);">',
'<span style="font-size:12px; text-transform:uppercase; color:var(--text-faint);">Live Sessions</span>',
'<div style="margin-top:8px; font-weight:700; color:var(--success);">Core Integrated</div>',
'</div>',
'<div class="hero-metric" style="background:rgba(255,255,255,0.03); padding:20px; border-radius:16px; border:1px solid var(--border);">',
'<span style="font-size:12px; text-transform:uppercase; color:var(--text-faint);">Ops Status</span>',
'<div style="margin-top:8px; font-weight:700; color:var(--accent);">Ready</div>',
'</div>',
'</div>',
'</section>'
].join("");
}
function renderPlanManage() {
var rows = state.plans || [];
return [
'<div class="toolbar" style="margin-bottom:24px;"><button class="btn btn-primary" data-action="plan-add">New Package</button></div>',
'<div class="table-wrap glass-card">',
renderTable(["ID", "Name", "Group", "Limit", "Price", "Status", "Actions"], rows.map(function(row) {
return [
'<code>' + row.id + '</code>',
'<strong>' + escapeHtml(row.name) + '</strong>',
'<span class="topbar-chip">' + escapeHtml(row.group_name || "-") + '</span>',
escapeHtml(row.transfer_enable + "GB"),
'<code>' + escapeHtml(row.prices) + '</code>',
renderStatus(row.show ? "visible" : "hidden"),
'<div class="row-actions"><button class="btn btn-ghost" data-action="plan-edit" data-id="' + row.id + '">Edit</button><button class="btn btn-ghost" data-action="plan-delete" data-id="' + row.id + '">Delete</button></div>'
];
})),
'</div>'
].join("");
}
function renderOrderManage() {
var rows = toArray(state.orders, "list");
return [
'<div class="table-wrap glass-card">',
renderTable(["Trade No", "User", "Plan", "Amount", "Status", "Actions"], rows.map(function(row) {
return [
'<code>' + row.trade_no + '</code>',
escapeHtml(row.user_email),
'<strong>' + escapeHtml(row.plan ? row.plan.name : "-") + '</strong>',
'¥' + (row.total_amount / 100).toFixed(2),
renderStatus(row.status == 0 ? "pending" : (row.status == 3 ? "paid" : "cancelled")),
'<div class="row-actions">' + (row.status == 0 ? '<button class="btn btn-ghost" data-action="order-paid" data-trade="' + row.trade_no + '">Approve</button><button class="btn btn-ghost" data-action="order-cancel" data-trade="' + row.trade_no + '">Cancel</button>' : '-') + '</div>'
];
})),
'</div>'
].join("");
}
function renderUserManage() {
var rows = toArray(state.users, "list");
return [
'<div class="table-wrap glass-card">',
renderTable(["ID", "Email", "Status", "Traffic", "Expiry", "Actions"], rows.map(function(row) {
return [
'<code>' + row.id + '</code>',
'<strong>' + escapeHtml(row.email) + '</strong>',
renderStatus(row.banned ? "banned" : "active"),
escapeHtml(formatTraffic(row.u + row.d) + " / " + formatTraffic(row.transfer_enable)),
escapeHtml(formatDate(row.expired_at)),
'<div class="row-actions"><button class="btn btn-ghost" data-action="user-edit" data-id="' + row.id + '">Edit</button><button class="btn btn-ghost" data-action="user-reset-traffic" data-id="' + row.id + '">Reset</button>' + (row.banned ? '<button class="btn btn-ghost" data-action="user-unban" data-id="' + row.id + '">Unban</button>' : '<button class="btn btn-ghost" data-action="user-ban" data-id="' + row.id + '">Ban</button>') + '</div>'
];
})),
'</div>',
renderPagination(state.users.pagination)
].join("");
}
function renderCouponManage() {
var rows = state.coupons || [];
return [
'<div class="toolbar" style="margin-bottom:24px;"><button class="btn btn-primary" data-action="coupon-add">Generate Coupon</button></div>',
'<div class="table-wrap glass-card">',
renderTable(["ID", "Name", "Code", "Type", "Value", "Actions"], rows.map(function(row) {
return [
'<code>' + row.id + '</code>',
escapeHtml(row.name),
'<strong>' + escapeHtml(row.code) + '</strong>',
row.type == 1 ? "Fix" : "Pct",
escapeHtml(row.type == 1 ? "¥" + (row.value/100).toFixed(2) : row.value + "%"),
'<div class="row-actions"><button class="btn btn-ghost" data-action="coupon-delete" data-id="' + row.id + '">Delete</button></div>'
];
})),
'</div>'
].join("");
}
function renderDashboardNode() {
var nodes = state.nodes || [];
return [
'<div class="table-wrap glass-card">',
renderTable(["ID", "Node Name", "Protocol", "Load", "U/D Traffic", "Status"], nodes.map(function(n) {
return [
'<code>' + n.id + '</code>',
'<strong>' + escapeHtml(n.name) + '</strong>',
'<span class="topbar-chip">' + escapeHtml(n.type) + '</span>',
renderTrafficBar(n.u + n.d, n.transfer_enable),
escapeHtml(formatTraffic(n.u) + " / " + formatTraffic(n.d)),
renderStatus(n.available_status)
];
})),
'</div>'
].join("");
}
function renderNodeManage() {
var rows = state.nodes || [];
return [
'<div class="toolbar" style="margin-bottom:24px;"><button class="btn btn-primary" data-action="node-add">Add New Node</button></div>',
'<div class="table-wrap glass-card">',
renderTable(["ID", "Name/Host", "Type", "Rate", "Visibility", "Actions"], rows.map(function(row) {
return [
escapeHtml(String(row.id)),
'<div><strong>' + escapeHtml(row.name) + '</strong><div style="font-size:12px; color:var(--text-faint);">' + escapeHtml(row.host) + '</div></div>',
escapeHtml(row.type),
'<strong>' + row.rate + 'x</strong>',
renderStatus(row.show ? "visible" : "hidden"),
'<div class="row-actions"><button class="btn btn-ghost" data-action="node-edit" data-id="' + row.id + '">Edit</button><button class="btn btn-ghost" data-action="node-copy" data-id="' + row.id + '">Copy</button><button class="btn btn-ghost" data-action="node-delete" data-id="' + row.id + '">Delete</button></div>'
];
})),
'</div>'
].join("");
}
function renderRealName() {
var rows = toArray(state.realname, "data");
return [
'<div class="toolbar" style="margin-bottom:24px;"><button class="btn btn-primary" data-action="approve-all">Approve All Pending</button><button class="btn btn-secondary" data-action="sync-all">Global Sync</button></div>',
'<div class="table-wrap glass-card">',
renderTable(["User ID", "Email", "Status", "Full Name", "Identity No", "Actions"], rows.map(function (row) {
return [
'<code>' + row.id + '</code>',
escapeHtml(row.email),
renderStatus(row.status),
'<strong>' + escapeHtml(row.real_name) + '</strong>',
'<code>' + escapeHtml(row.identity_no_masked) + '</code>',
'<div class="row-actions"><button class="btn btn-ghost" data-action="review" data-user-id="' + row.id + '" data-status="approved">Approve</button><button class="btn btn-ghost" data-action="review" data-user-id="' + row.id + '" data-status="rejected">Reject</button></div>'
];
})),
'</div>'
].join("");
}
function renderOnlineDevices() {
var rows = toArray(state.devices, "list");
return [
'<div class="table-wrap glass-card">',
renderTable(["User Email", "Subscription", "IPs", "Last Seen"], rows.map(function (row) {
return [
'<strong>' + escapeHtml(row.email) + '</strong>',
'<span class="topbar-chip">' + escapeHtml(row.subscription_name) + '</span>',
'<div style="max-width:300px; overflow:hidden; text-overflow:ellipsis;">' + escapeHtml((row.online_devices || []).join(", ")) + '</div>',
escapeHtml(row.last_online_text)
];
})),
'</div>'
].join("");
}
function renderNodeGroup() {
var rows = state.groups || [];
return [
'<div class="toolbar" style="margin-bottom:24px;"><button class="btn btn-primary" data-action="group-add">Add Group</button></div>',
'<div class="table-wrap glass-card">',
renderTable(["ID", "Group Name", "Node Count", "User Count", "Actions"], rows.map(function(row) {
return [
'<code>' + row.id + '</code>',
'<strong>' + escapeHtml(row.name) + '</strong>',
'<code>' + (row.server_count || 0) + '</code>',
'<code>' + (row.user_count || 0) + '</code>',
'<div class="row-actions"><button class="btn btn-ghost" data-action="group-edit" data-id="' + row.id + '">Edit</button><button class="btn btn-ghost" data-action="group-delete" data-id="' + row.id + '">Delete</button></div>'
];
})),
'</div>'
].join("");
}
function renderNodeRoute() {
var rows = state.routes || [];
return [
'<div class="toolbar" style="margin-bottom:24px;"><button class="btn btn-primary" data-action="route-add">Add Route</button></div>',
'<div class="table-wrap glass-card">',
renderTable(["ID", "Remarks", "Match", "Action", "Actions"], rows.map(function(row) {
return [
'<code>' + row.id + '</code>',
'<strong>' + escapeHtml(row.remarks) + '</strong>',
'<div style="font-size:12px;">' + (row.match || []).join(", ") + '</div>',
'<code>' + row.action + '</code>',
'<div class="row-actions"><button class="btn btn-ghost" data-action="route-edit" data-id="' + row.id + '">Edit</button><button class="btn btn-ghost" data-action="route-delete" data-id="' + row.id + '">Delete</button></div>'
];
})),
'</div>'
].join("");
}
function renderSystemConfig() {
var cfg = state.config || {};
var tab = state.configTab || "site";
var sections = ["site", "subscribe", "server", "email", "telegram", "safe"];
var tabData = cfg[tab] || {};
return [
'<div class="tabs-nav glass-card" style="margin-bottom:24px; padding:8px; display:flex; gap:8px; overflow-x:auto;">',
sections.map(s => '<button class="btn ' + (tab === s ? "btn-primary" : "btn-ghost") + '" data-action="config-tab" data-tab="' + s + '">' + s.toUpperCase() + '</button>').join(""),
'</div>',
'<form data-form="config-save" class="glass-card card fade-in" style="padding:40px;">',
'<div class="grid grid-2">',
Object.keys(tabData).map(key => {
var val = tabData[key];
var label = key.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
var input = '<input name="' + key + '" value="' + (val === null ? "" : val) + '" />';
if (typeof val === "boolean") {
input = '<select name="' + key + '"><option value="1" ' + (val ? "selected" : "") + '>Enabled</option><option value="0" ' + (val ? "" : "selected") + '>Disabled</option></select>';
}
return '<div class="field"><label>' + label + '</label>' + input + '</div>';
}).join(""),
'</div>',
'<div style="margin-top:32px; border-top:1px solid var(--border); padding-top:32px;">',
'<button class="btn btn-primary" type="submit">Apply System Settings</button>',
'</div>',
'</form>'
].join("");
}
// Helpers
function statCard(title, value, hint) {
return [
'<article class="card glass-card stat-card">',
'<span>' + escapeHtml(title) + '</span>',
'<strong>' + escapeHtml(value) + '</strong>',
'<p style="margin:0; font-size:13px; color:var(--text-faint);">' + escapeHtml(hint) + '</p>',
'</article>'
].join("");
}
function renderTable(headers, rows) {
if (!rows.length) return '<div class="empty-state">No records found.</div>';
return [
'<table><thead><tr>',
headers.map(function(h) { return '<th>' + escapeHtml(h) + '</th>'; }).join(""),
'</tr></thead><tbody>',
rows.map(function(row) { return '<tr>' + row.map(function(c) { return '<td>' + c + '</td>'; }).join("") + '</tr>'; }).join(""),
'</tbody></table>'
].join("");
}
function renderTrafficBar(used, total) {
if (!total) return '<div style="font-size:12px;">Unlimited</div>';
var pct = Math.min(100, Math.floor((used / total) * 100));
return '<div class="progress-bar"><div class="progress-fill" style="width:' + pct + '%;"></div></div>';
}
function renderStatus(s) {
var raw = String(s || "").toLowerCase();
var type = (raw.indexOf("ok") !== -1 || raw.indexOf("approved") !== -1 || raw.indexOf("active") !== -1 || raw.indexOf("visible") !== -1) ? "ok" :
(raw.indexOf("pending") !== -1 || raw.indexOf("warn") !== -1) ? "warn" : "danger";
return '<span class="status-pill status-' + type + '">' + escapeHtml(s) + '</span>';
}
function navItem(route, title, desc) {
var active = state.route === route ? "active" : "";
return '<a class="sidebar-item ' + active + '" data-action="nav" data-route="' + route + '" href="#' + route + '"><strong>' + escapeHtml(title) + '</strong><span>' + escapeHtml(desc) + '</span></a>';
}
function renderPagination(p) {
if (!p || p.last_page <= 1) return "";
var btns = [];
for (var i = 1; i <= p.last_page; i++) {
btns.push('<button class="page-btn ' + (p.current === i ? "active" : "") + '" data-action="page" data-page="' + i + '">' + i + '</button>');
}
return '<div class="pagination-shell">' + btns.join("") + '</div>';
}
function formatTraffic(bytes) {
if (!bytes) return "0 B";
var units = ["B", "KB", "MB", "GB", "TB"];
var i = 0; while (bytes >= 1024 && i < 4) { bytes /= 1024; i++; }
return bytes.toFixed(2) + " " + units[i];
}
function formatDate(v) {
if (!v) return "-";
var d = new Date(typeof v === "number" ? v * 1000 : v);
return isNaN(d) ? String(v) : d.toLocaleString();
}
function escapeHtml(v) {
return String(v || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function show(msg, type) { state.message = msg; state.messageType = type; render(); setTimeout(function() { state.message = ""; render(); }, 4000); }
function request(url, opt) {
opt = opt || {};
var h = { "Content-Type": "application/json" };
if (opt.auth !== false && state.token) h.Authorization = state.token;
return fetch(url, { method: opt.method || "GET", headers: h, body: opt.body ? JSON.stringify(opt.body) : undefined }).then(async function(r) {
var p = await r.json().catch(() => null);
if (!r.ok) { if (r.status === 401) { clearSession(); render(); } throw new Error((p && (p.message || p.msg)) || "Error"); }
return p;
});
}
function adminPost(url, body) { return request(url, { method: "POST", body: body }).then(function(r) { show("Success", "success"); return r; }).catch(function(e) { show(e.message, "error"); throw e; }); }
function unwrap(p) { return p && typeof p.data !== "undefined" ? p.data : p; }
function toArray(v, k) { if (Array.isArray(v)) return v; if (!v || typeof v !== "object") return []; return Array.isArray(v[k || "data"]) ? v[k || "data"] : (Array.isArray(v.list) ? v.list : []); }
function readToken() { return window.localStorage.getItem("__gopanel_admin_auth__") || ""; }
function saveToken(t) { window.localStorage.setItem("__gopanel_admin_auth__", /^Bearer /.test(t) ? t : "Bearer " + t); }
function clearToken() { window.localStorage.removeItem("__gopanel_admin_auth__"); }
function readRoute() { return (window.location.hash || "#overview").slice(1); }
function normalizeRoute(r) { return r || "overview"; }
function getSecurePath() { return (state.config && state.config.secure_path) || cfg.securePath || "admin"; }
function serializeForm(f) {
var r = {};
new FormData(f).forEach((v, k) => {
// Cast numeric fields
if (["id", "group_id", "sort", "transfer_enable", "speed_limit", "reset_traffic_method", "capacity_limit", "device_limit", "balance", "commission_type", "commission_rate", "commission_balance", "plan_id", "type", "value"].includes(k)) {
if (v === "" || v === null) r[k] = null;
else r[k] = Number(v);
} else if (k === "show" || k === "renew" || k === "sell") {
r[k] = v === "1";
} else if (k === "expired_at") {
if (v === "") r[k] = null;
else r[k] = Math.floor(new Date(v).getTime() / 1000);
} else {
r[k] = v;
}
});
return r;
}
function renderNodeForm(d) {
return [
'<h2>' + (d.id ? "Edit Node" : "Create Node") + '</h2>',
'<form data-form="node-save" style="max-height:70vh; overflow-y:auto; padding-right:12px;">',
'<input type="hidden" name="id" value="' + (d.id || "") + '" />',
'<div class="grid grid-2">',
'<div class="field"><label>Node Name</label><input name="name" value="' + (d.name || "") + '" required /></div>',
'<div class="field"><label>Node Type</label><input name="type" value="' + (d.type || "shadowsocks") + '" required /></div>',
'<div class="field"><label>Server Domain/IP</label><input name="host" value="' + (d.host || "") + '" required /></div>',
'<div class="field"><label>Public Port</label><input name="port" value="' + (d.port || "") + '" required /></div>',
'<div class="field"><label>Internal Port</label><input type="number" name="server_port" value="' + (d.server_port || 443) + '" required /></div>',
'<div class="field"><label>Rate multiplier</label><input type="number" step="0.1" name="rate" value="' + (d.rate || 1.0) + '" /></div>',
'</div>',
'<div class="field"><label>Visibility</label><select name="show"><option value="1">Visible</option><option value="0" ' + (d.show ? "" : "selected") + '>Hidden</option></select></div>',
'<button class="btn btn-primary" type="submit">Save Node</button>',
'</form>'
].join("");
}
function renderGroupForm(d) {
return [
'<h2>' + (d.id ? "Edit Group" : "Create Group") + '</h2>',
'<form data-form="group-save">',
'<input type="hidden" name="id" value="' + (d.id || "") + '" />',
'<div class="field"><label>Group Name</label><input name="name" value="' + (d.name || "") + '" required /></div>',
'<button class="btn btn-primary" type="submit">Save Group</button>',
'</form>'
].join("");
}
function renderRouteForm(d) {
return [
'<h2>' + (d.id ? "Edit Route" : "Create Route") + '</h2>',
'<form data-form="route-save">',
'<input type="hidden" name="id" value="' + (d.id || "") + '" />',
'<div class="field"><label>Remarks</label><input name="remarks" value="' + (d.remarks || "") + '" required /></div>',
'<div class="field"><label>Action</label><select name="action"><option value="direct">Direct</option><option value="proxy">Proxy</option><option value="block">Block</option></select></div>',
'<button class="btn btn-primary" type="submit">Save Route</button>',
'</form>'
].join("");
}
function getRouteTitle(r) { return r.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()); }
function getRouteDescription(r) { return "SingBox Gopanel integrated module management."; }
})();