Files
SingBox-Gopanel/frontend/admin/app.js
CN-JS-HuiBai 1ed31b9292
All checks were successful
build / build (api, amd64, linux) (push) Successful in -47s
build / build (api, arm64, linux) (push) Successful in -48s
build / build (api.exe, amd64, windows) (push) Successful in -47s
first commit
2026-04-17 09:49:16 +08:00

695 lines
24 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,
plugins: [],
integration: {},
realname: null,
devices: null,
busy: false
};
boot();
async function boot() {
window.addEventListener("hashchange", function () {
state.route = normalizeRoute(readRoute());
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" }),
request(api.plugins, { method: "GET" }),
request(api.integration, { method: "GET" })
]);
state.config = unwrap(results[0]) || {};
state.system = unwrap(results[1]) || {};
state.plugins = toArray(unwrap(results[2]));
state.integration = unwrap(results[3]) || {};
} catch (error) {
clearSession();
show(error.message || "Failed to load admin data.", "error");
} finally {
state.busy = false;
}
}
async function hydrateRoute() {
if (!state.user) {
return;
}
try {
if (state.route === "realname") {
state.realname = unwrap(await request(api.realnameBase + "/records?per_page=50", { method: "GET" })) || {};
render();
return;
}
if (state.route === "user-online-devices") {
state.devices = unwrap(await request(api.onlineDevices + "?per_page=50", { method: "GET" })) || {};
render();
}
} catch (error) {
show(error.message || "Failed to load page data.", "error");
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 === "approve-all") {
adminPost(api.realnameBase + "/approve-all", {}, "Approved all pending records.").then(hydrateRoute);
return;
}
if (action === "sync-all") {
adminPost(api.realnameBase + "/sync-all", {}, "Triggered real-name sync.").then(hydrateRoute);
return;
}
if (action === "clear-cache") {
adminPost(api.realnameBase + "/clear-cache", {}, "Cleared plugin cache.");
return;
}
if (action === "review") {
var userId = actionEl.getAttribute("data-user-id");
var status = actionEl.getAttribute("data-status") || "approved";
var reason = "";
if (status === "rejected") {
reason = window.prompt("Reject reason", "") || "";
}
adminPost(
api.realnameBase + "/review/" + encodeURIComponent(userId),
{ status: status, reason: reason },
"Updated review result."
).then(hydrateRoute);
return;
}
if (action === "reset-record") {
var resetUserId = actionEl.getAttribute("data-user-id");
adminPost(
api.realnameBase + "/reset/" + encodeURIComponent(resetUserId),
{},
"Reset the verification record."
).then(hydrateRoute);
}
}
function onSubmit(event) {
var form = event.target;
if (form.getAttribute("data-form") !== "login") {
return;
}
event.preventDefault();
var formData = serializeForm(form);
request("/api/v1/passport/auth/login", {
method: "POST",
auth: false,
body: formData
}).then(function (response) {
var payload = unwrap(response);
if (!payload || !payload.auth_data || !payload.is_admin) {
throw new Error("This account does not have admin access.");
}
saveToken(payload.auth_data);
state.token = readToken();
return loadBootstrap();
}).then(function () {
show("Admin login successful.", "success");
render();
hydrateRoute();
}).catch(function (error) {
show(error.message || "Login failed.", "error");
render();
});
}
function refreshAll() {
state.realname = null;
state.devices = null;
state.busy = true;
render();
loadBootstrap().then(function () {
render();
return hydrateRoute();
});
}
function clearSession() {
clearToken();
state.user = null;
state.config = null;
state.system = null;
state.plugins = [];
state.integration = {};
state.realname = null;
state.devices = null;
state.route = "overview";
state.busy = false;
}
function render() {
root.innerHTML = state.user ? renderDashboard() : renderLogin();
}
function renderLogin() {
return [
'<div class="login-shell">',
'<section class="login-card stack">',
'<div><h1 style="margin:0 0 8px;">' + escapeHtml(cfg.title || "Admin") + '</h1><p class="hint">Use an administrator account to sign in and open the rebuilt backend workspace.</p></div>',
state.message ? renderNotice() : "",
'<form data-form="login">',
'<div class="field"><label>Email</label><input type="email" name="email" autocomplete="username" required /></div>',
'<div class="field"><label>Password</label><input type="password" name="password" autocomplete="current-password" required /></div>',
'<button class="btn btn-primary" type="submit">Sign In</button>',
'</form>',
'<div class="hint">The backend now uses a single Go-rendered admin shell with integrated plugin pages.</div>',
'</section>',
'</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() {
var items = [
navItem("overview", "Overview", "System status, plugin visibility, and entry summary"),
navItem("realname", "Real Name", "Review and operate the verification workflow"),
navItem("user-online-devices", "Online Devices", "Inspect user sessions and online device data"),
navItem("ipv6-subscription", "IPv6 Subscription", "Check the IPv6 shadow-account integration state"),
navItem("plugin-status", "Plugin Status", "Compare current backend integrations by module")
];
return [
'<aside class="admin-sidebar">',
'<div class="sidebar-brand"><span class="sidebar-brand-mark">Console</span><strong>' + escapeHtml(cfg.title || "Admin") + '</strong><span>Secure Path: /' + escapeHtml(getSecurePath()) + '</span></div>',
'<div class="sidebar-nav-label">Workspace</div>',
'<nav class="sidebar-nav">' + items.join("") + '</nav>',
'<div class="sidebar-meta"><span class="sidebar-meta-label">Current View</span><strong>' + escapeHtml(getRouteTitle(state.route)) + '</strong></div>',
'<div class="sidebar-footer">The layout keeps the classic backend pattern: dark sidebar, top workspace bar, and a card-based content area.</div>',
'</aside>'
].join("");
}
function renderTopbar() {
return [
'<header class="topbar">',
'<div class="topbar-copy">',
'<span class="topbar-eyebrow">Admin Workspace</span>',
'<h1>' + escapeHtml(getRouteTitle(state.route)) + '</h1>',
'<p>' + escapeHtml(getRouteDescription(state.route)) + '</p>',
'<div class="topbar-meta">',
'<span class="topbar-chip">Secure Path /' + escapeHtml(getSecurePath()) + '</span>',
'<span class="topbar-chip">Server Time ' + escapeHtml(formatDate(state.system && state.system.server_time)) + '</span>',
state.busy ? '<span class="topbar-chip">Refreshing</span>' : "",
'</div>',
'</div>',
'<div class="toolbar"><button class="btn btn-secondary" data-action="refresh">Refresh</button><button class="btn btn-ghost" data-action="logout">Logout</button></div>',
'</header>'
].join("");
}
function renderMainContent() {
if (state.route === "realname") {
return renderRealName();
}
if (state.route === "user-online-devices") {
return renderOnlineDevices();
}
if (state.route === "ipv6-subscription") {
return renderIPv6Integration();
}
if (state.route === "plugin-status") {
return renderPluginStatus();
}
return renderOverview();
}
function renderOverview() {
var enabledCount = countEnabledPlugins();
return [
'<section class="page-hero card">',
'<div class="page-hero-copy">',
'<span class="page-hero-label">Overview</span>',
'<h2>Classic backend structure, rebuilt with the current Go APIs.</h2>',
'<p>The page keeps the familiar admin navigation pattern while exposing plugin data, system state, and backend integration results in one place.</p>',
'</div>',
'<div class="page-hero-side">',
'<div class="hero-metric"><span>Enabled Plugins</span><strong>' + escapeHtml(String(enabledCount)) + '</strong></div>',
'<div class="hero-metric"><span>Secure Path</span><strong>/' + escapeHtml(getSecurePath()) + '</strong></div>',
'</div>',
'</section>',
'<section class="grid grid-3">',
statCard("Server Time", formatDate(state.system && state.system.server_time), "Source: /system/getSystemStatus"),
statCard("Admin Path", "/" + getSecurePath(), "Synced from current backend settings"),
statCard("Plugin Count", String((state.plugins || []).length), "Read from the integrated plugin list"),
'</section>',
'<section class="card page-section"><div class="section-headline"><div><span class="section-kicker">Plugins</span><h2>Integrated plugin list</h2></div><p class="section-copy">Each entry reflects the current Go backend response and the copied integration status.</p></div><div class="table-wrap">' + renderPluginsTable() + '</div></section>'
].join("");
}
function renderRealName() {
var rows = toArray(state.realname, "data");
var loading = state.realname === null;
return [
'<section class="card stack page-section">',
'<div class="section-headline"><div><span class="section-kicker">Workflow</span><h2>Real-name verification</h2></div><p class="section-copy">Batch actions stay in the toolbar while the review table keeps the classic admin operating rhythm.</p></div>',
'<div class="toolbar"><button class="btn btn-primary" data-action="approve-all">Approve All</button><button class="btn btn-secondary" data-action="sync-all">Sync All</button><button class="btn btn-ghost" data-action="clear-cache">Clear Cache</button></div>',
'<div class="table-wrap"><table><thead><tr><th>ID</th><th>Email</th><th>Status</th><th>Real Name</th><th>ID Number</th><th>Submitted At</th><th>Actions</th></tr></thead><tbody>',
loading ? '<tr><td colspan="7">Loading verification records...</td></tr>' : rows.length ? rows.map(function (row) {
return [
'<tr>',
'<td>' + escapeHtml(String(row.id || "")) + '</td>',
'<td>' + escapeHtml(row.email || "-") + '</td>',
'<td>' + renderStatus(row.status) + '</td>',
'<td>' + escapeHtml(row.real_name || "-") + '</td>',
'<td>' + escapeHtml(row.identity_no_masked || "-") + '</td>',
'<td>' + escapeHtml(formatDate(row.submitted_at)) + '</td>',
'<td><div class="row-actions"><button class="btn btn-secondary" data-action="review" data-user-id="' + escapeHtml(String(row.id || "")) + '" data-status="approved">Approve</button><button class="btn btn-ghost" data-action="review" data-user-id="' + escapeHtml(String(row.id || "")) + '" data-status="rejected">Reject</button><button class="btn btn-ghost" data-action="reset-record" data-user-id="' + escapeHtml(String(row.id || "")) + '">Reset</button></div></td>',
'</tr>'
].join("");
}).join("") : '<tr><td colspan="7">No verification records available.</td></tr>',
'</tbody></table></div>',
'</section>'
].join("");
}
function renderOnlineDevices() {
var rows = toArray(state.devices, "list");
var loading = state.devices === null;
return [
'<section class="card stack page-section">',
'<div class="section-headline"><div><span class="section-kicker">Monitoring</span><h2>Online devices</h2></div><p class="section-copy">This table keeps the admin-friendly scan pattern for sessions, subscription names, IPs, and recent activity.</p></div>',
'<div class="table-wrap"><table><thead><tr><th>User</th><th>Subscription</th><th>Online Count</th><th>Online IPs</th><th>Last Seen</th><th>Created At</th></tr></thead><tbody>',
loading ? '<tr><td colspan="6">Loading device records...</td></tr>' : rows.length ? rows.map(function (row) {
return [
'<tr>',
'<td>' + escapeHtml(row.email || "-") + '</td>',
'<td>' + escapeHtml(row.subscription_name || "-") + '</td>',
'<td>' + escapeHtml(String(row.online_count || 0)) + '</td>',
'<td>' + escapeHtml(formatDeviceList(row.online_devices)) + '</td>',
'<td>' + escapeHtml(row.last_online_text || "-") + '</td>',
'<td>' + escapeHtml(row.created_text || "-") + '</td>',
'</tr>'
].join("");
}).join("") : '<tr><td colspan="6">No online device records available.</td></tr>',
'</tbody></table></div>',
'</section>'
].join("");
}
function renderIPv6Integration() {
var integration = (state.integration && state.integration.user_add_ipv6_subscription) || {};
return [
'<section class="card stack page-section">',
'<div class="section-headline"><div><span class="section-kicker">Integration</span><h2>IPv6 shadow subscription</h2></div><p class="section-copy">' + escapeHtml(buildSummary(integration, "This panel shows the current runtime status of the IPv6 shadow-account integration.")) + '</p></div>',
'<div class="status-strip">' + renderStatus(integration.status || "unknown") + '</div>',
'<pre class="code-panel">' + escapeHtml(stringifyJSON(integration)) + '</pre>',
'</section>'
].join("");
}
function renderPluginStatus() {
var cards = [
sectionCard("Real-name verification", state.integration && state.integration.real_name_verification),
sectionCard("Online devices", state.integration && state.integration.user_online_devices),
sectionCard("IPv6 shadow subscription", state.integration && state.integration.user_add_ipv6_subscription)
];
return '<section class="grid plugin-grid">' + cards.join("") + '</section>';
}
function renderPluginsTable() {
var rows = toArray(state.plugins);
return '<table><thead><tr><th>ID</th><th>Code</th><th>Status</th><th>Config</th></tr></thead><tbody>' + (rows.length ? rows.map(function (row) {
return '<tr><td>' + escapeHtml(String(row.id || "")) + '</td><td>' + escapeHtml(row.code || row.name || "-") + '</td><td>' + renderStatus(row.is_enabled ? "enabled" : "disabled") + '</td><td><pre class="table-code">' + escapeHtml(formatPluginConfig(row.config)) + '</pre></td></tr>';
}).join("") : '<tr><td colspan="4">No plugin data available.</td></tr>') + '</tbody></table>';
}
function sectionCard(title, data) {
data = data || {};
return [
'<article class="card stack plugin-card">',
'<div class="section-headline"><div><span class="section-kicker">Module</span><h2>' + escapeHtml(title) + '</h2></div></div>',
'<p class="section-copy">' + escapeHtml(buildSummary(data, "No summary has been returned by the backend for this module.")) + '</p>',
renderStatus(data.status || "unknown"),
'<pre class="code-panel">' + escapeHtml(stringifyJSON(data)) + '</pre>',
'</article>'
].join("");
}
function navItem(route, title, desc) {
return '<a class="sidebar-item ' + (state.route === route ? "active" : "") + '" data-action="nav" data-route="' + escapeHtml(route) + '" href="#' + escapeHtml(route) + '"><strong>' + escapeHtml(title) + '</strong><span>' + escapeHtml(desc) + '</span></a>';
}
function statCard(title, value, hint) {
return '<article class="card stat stat-card"><span class="hint">' + escapeHtml(title) + '</span><strong>' + escapeHtml(value || "-") + '</strong><p>' + escapeHtml(hint || "") + '</p></article>';
}
function renderNotice() {
return '<div class="notice ' + escapeHtml(state.messageType || "") + '">' + escapeHtml(state.message || "") + '</div>';
}
function countEnabledPlugins() {
return toArray(state.plugins).filter(function (item) {
return !!item.is_enabled;
}).length;
}
function buildSummary(data, fallback) {
if (!data) {
return fallback;
}
if (typeof data.summary === "string" && data.summary.trim()) {
return data.summary.trim();
}
if (typeof data.message === "string" && data.message.trim()) {
return data.message.trim();
}
return fallback;
}
function formatPluginConfig(value) {
if (typeof value === "string" && value.trim()) {
return value;
}
return stringifyJSON(value || {});
}
function stringifyJSON(value) {
try {
return JSON.stringify(value == null ? {} : value, null, 2);
} catch (error) {
return String(value == null ? "" : value);
}
}
function formatDeviceList(value) {
if (Array.isArray(value)) {
return value.join(", ") || "-";
}
if (typeof value === "string" && value.trim()) {
return value;
}
return "-";
}
function toArray(value, preferredKey) {
if (Array.isArray(value)) {
return value;
}
if (!value || typeof value !== "object") {
return [];
}
if (preferredKey && Array.isArray(value[preferredKey])) {
return value[preferredKey];
}
if (Array.isArray(value.data)) {
return value.data;
}
if (Array.isArray(value.list)) {
return value.list;
}
return [];
}
function request(url, options) {
options = options || {};
var headers = {
"Content-Type": "application/json"
};
if (options.auth !== false && state.token) {
headers.Authorization = state.token;
}
return fetch(url, {
method: options.method || "GET",
headers: headers,
credentials: "same-origin",
body: options.body ? JSON.stringify(options.body) : undefined
}).then(async function (response) {
var payload = null;
try {
payload = await response.json();
} catch (error) {
payload = null;
}
if (!response.ok) {
if (response.status === 401) {
clearSession();
render();
}
throw new Error(getErrorMessage(payload) || "Request failed.");
}
return payload;
});
}
function adminPost(url, body, successMessage) {
return request(url, {
method: "POST",
body: body || {}
}).then(function (payload) {
show(successMessage || "Operation completed.", "success");
render();
return payload;
}).catch(function (error) {
show(error.message || "Operation failed.", "error");
render();
throw error;
});
}
function unwrap(payload) {
if (!payload) {
return null;
}
return typeof payload.data !== "undefined" ? payload.data : payload;
}
function getErrorMessage(payload) {
if (!payload) {
return "";
}
return payload.message || payload.msg || payload.error || "";
}
function saveToken(token) {
var normalized = /^Bearer /.test(token) ? token : "Bearer " + token;
window.localStorage.setItem("__gopanel_admin_auth__", normalized);
}
function serializeForm(form) {
var result = {};
var formData = new FormData(form);
formData.forEach(function (value, key) {
result[key] = value;
});
return result;
}
function readToken() {
var token = window.localStorage.getItem("__gopanel_admin_auth__") ||
window.localStorage.getItem("__nebula_auth_data__") ||
window.localStorage.getItem("auth_data") ||
"";
if (!token) {
return "";
}
return /^Bearer /.test(token) ? token : "Bearer " + token;
}
function clearToken() {
window.localStorage.removeItem("__gopanel_admin_auth__");
state.token = "";
}
function readRoute() {
return (window.location.hash || "#overview").slice(1) || "overview";
}
function normalizeRoute(route) {
var allowed = {
overview: true,
realname: true,
"user-online-devices": true,
"ipv6-subscription": true,
"plugin-status": true
};
return allowed[route] ? route : "overview";
}
function getSecurePath() {
return (state.config && state.config.secure_path) || cfg.securePath || "admin";
}
function getRouteTitle(route) {
if (route === "realname") {
return "Real-name Verification";
}
if (route === "user-online-devices") {
return "Online Devices";
}
if (route === "ipv6-subscription") {
return "IPv6 Subscription";
}
if (route === "plugin-status") {
return "Plugin Status";
}
return "Overview";
}
function getRouteDescription(route) {
if (route === "realname") {
return "Review records, run batch actions, and keep the original backend workflow readable.";
}
if (route === "user-online-devices") {
return "Inspect active users, current devices, and recent online activity in one table.";
}
if (route === "ipv6-subscription") {
return "Check the replicated backend integration output for the IPv6 shadow-account module.";
}
if (route === "plugin-status") {
return "Compare plugin integration payloads and runtime summaries side by side.";
}
return "A familiar backend workspace with sidebar navigation, top control bar, and focused content cards.";
}
function show(message, type) {
state.message = message || "";
state.messageType = type || "";
}
function formatDate(value) {
if (!value) {
return "-";
}
var numeric = Number(value);
if (!Number.isNaN(numeric) && numeric > 0) {
return new Date(numeric * 1000).toLocaleString();
}
var parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toLocaleString();
}
return String(value);
}
function renderStatus(status) {
var raw = String(status || "unknown");
var normalized = raw.toLowerCase();
var klass = "status-pill status-ok";
if (normalized.indexOf("warn") !== -1 || normalized.indexOf("pending") !== -1 || normalized.indexOf("runtime") !== -1) {
klass = "status-pill status-warn";
}
if (normalized.indexOf("disabled") !== -1 || normalized.indexOf("fail") !== -1 || normalized.indexOf("error") !== -1 || normalized.indexOf("reject") !== -1 || normalized.indexOf("unknown") !== -1) {
klass = "status-pill status-danger";
}
return '<span class="' + klass + '">' + escapeHtml(raw) + '</span>';
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
})();