869 lines
40 KiB
JavaScript
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">×</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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
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."; }
|
|
|
|
})();
|