(function () { "use strict"; var theme = window.NEBULA_THEME || {}; var app = document.getElementById("app"); var loader = document.getElementById("nebula-loader"); var themeMediaQuery = getThemeMediaQuery(); if (!app) { return; } var state = { mode: "login", loading: true, message: "", messageType: "", themePreference: getStoredThemePreference(), themeMode: "dark", uplinkMbps: null, currentRoute: getCurrentRoute(), nodePage: 1, localIp: null, reportedIps: [], authToken: getStoredToken(), user: null, subscribe: null, stats: null, knowledge: [], tickets: [], selectedTicketId: null, selectedTicket: null, servers: [], ipv6AuthToken: getStoredIpv6Token(), ipv6User: null, ipv6Subscribe: null, ipv6Eligibility: null, ipv6Servers: [], sessionOverview: null, ipv6SessionOverview: null, realNameVerification: null, appConfig: null, isSidebarOpen: false, serverSearch: "", cachedSubNodes: null, ipv6CachedSubNodes: null }; init(); async function init() { applyThemeMode(); applyCustomBackground(); loadUplinkMetric(); window.setInterval(loadUplinkMetric, 5000); try { var verify = getVerifyToken(); if (verify) { var verifyResponse = await fetchJson("/api/v1/passport/auth/token2Login?verify=" + encodeURIComponent(verify), { method: "GET", auth: false }); var verifyPayload = unwrap(verifyResponse); if (verifyPayload && verifyPayload.auth_data) { saveToken(verifyPayload.auth_data); clearVerifyToken(); state.authToken = getStoredToken(); } } } catch (error) { showMessage(error.message || "Quick login failed", "error"); } if (state.authToken) { var loaded = await loadDashboard(); if (!loaded) { state.loading = false; } } else { state.loading = false; } render(); app.addEventListener("click", onClick); app.addEventListener("input", onInput); app.addEventListener("submit", onSubmit); window.addEventListener("hashchange", onRouteChange); bindSystemThemeListener(); } async function loadDashboard(silent) { if (!state.authToken) { state.loading = false; return false; } state.loading = !silent; if (!silent) { render(); } try { var results = await Promise.all([ fetchJson("/api/v1/user/info", { method: "GET" }), fetchJson("/api/v1/user/getSubscribe", { method: "GET" }), fetchJson("/api/v1/user/getStat", { method: "GET" }), fetchJson("/api/v1/user/server/fetch", { method: "GET" }), fetchJson("/api/v1/user/ticket/fetch", { method: "GET" }), fetchSessionOverview(), fetchJson("/api/v1/user/comm/config", { method: "GET" }), fetchRealNameVerificationStatus(), fetchJson("/api/v1/user/user-add-ipv6-subscription/check", { method: "GET" }) ]); state.user = unwrap(results[0]); state.subscribe = unwrap(results[1]); state.stats = unwrap(results[2]); state.servers = unwrap(results[3]) || []; state.knowledge = []; state.tickets = unwrap(results[4]) || []; state.sessionOverview = results[5]; state.appConfig = unwrap(results[6]) || {}; state.realNameVerification = results[7] || null; state.ipv6Eligibility = unwrap(results[8]) || null; if (state.ipv6AuthToken) { try { var ipv6Results = await Promise.all([ fetchJson("/api/v1/user/info", { method: "GET", ipv6: true }), fetchJson("/api/v1/user/getSubscribe", { method: "GET", ipv6: true }), fetchJson("/api/v1/user/server/fetch", { method: "GET", ipv6: true }), fetchSessionOverview(true) ]); state.ipv6User = unwrap(ipv6Results[0]); state.ipv6Subscribe = unwrap(ipv6Results[1]); state.ipv6Servers = unwrap(ipv6Results[2]) || []; state.ipv6SessionOverview = ipv6Results[3]; } catch (e) { if (handleIpv6AuthFailure(e)) { console.warn("Nebula: cleared stale IPv6 session after unauthorized response"); } console.error("Failed to load IPv6 dashboard data", e); } } state.nodePage = 1; state.loading = false; return true; } catch (error) { state.loading = false; if (error.status === 401 || error.status === 403) { var hadSession = Boolean(state.authToken); clearToken(); resetDashboard(); showMessage(hadSession ? "Session expired. Please sign in again." : "", hadSession ? "error" : ""); return false; } showMessage(error.message || "Failed to load dashboard", "error"); return false; } } async function fetchSessionOverview(isIpv6) { try { var response = await fetchJson("/api/v1/user/user-online-devices/get-ip", { method: "GET", ipv6: !!isIpv6 }); var ipPayload = unwrap(response) || {}; var reportedIps = ipPayload.ips || []; if (!isIpv6) { state.reportedIps = reportedIps; } var overview = ipPayload.session_overview || { sessions: [], online_ips: [] }; if (!isIpv6) { overview.online_ips = state.reportedIps; } return buildSessionOverview(overview.sessions, overview, !!isIpv6); } catch (error) { if (isIpv6 && handleIpv6AuthFailure(error)) { return null; } console.error("Nebula: Failed to synchronize online status", error); // Fallback for sessions if plugin fails try { var sessionsFallback = unwrap(await fetchJson("/api/v1/user/getActiveSession", { method: "GET", ipv6: !!isIpv6 })) || []; return buildSessionOverview(sessionsFallback, {}, !!isIpv6); } catch (e) { if (isIpv6 && handleIpv6AuthFailure(e)) { return null; } return buildSessionOverview([], {}, !!isIpv6); } } } async function fetchRealNameVerificationStatus() { try { var response = await fetchJson("/api/v1/user/real-name-verification/status", { method: "GET" }); var payload = unwrap(response) || {}; return Object.assign({ enabled: true, status: "unverified", status_label: "未认证", can_submit: true, real_name: "", identity_no_masked: "", notice: "", reject_reason: "", submitted_at: null, reviewed_at: null }, payload); } catch (error) { return { enabled: false, status: "unavailable", status_label: "Unavailable", can_submit: false, real_name: "", identity_no_masked: "", notice: "", reject_reason: "", submitted_at: null, reviewed_at: null }; } } async function fetchJson(url, options) { options = options || {}; var headers = { "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest" }; if (options.auth !== false) { var token = options.ipv6 ? (state.ipv6AuthToken || getStoredIpv6Token()) : (state.authToken || getStoredToken()); if (token) { headers.Authorization = normalizeAuthHeader(token); } } var response = await fetch(url, { method: options.method || "GET", headers: headers, credentials: "same-origin", body: options.body ? JSON.stringify(options.body) : undefined }); var payload = null; try { payload = await response.json(); } catch (error) { payload = null; } if (!response.ok) { var message = payload && (payload.message || payload.error) || "Request failed"; var err = new Error(message); err.status = response.status; throw err; } return payload; } function unwrap(payload) { if (!payload) { return null; } if (typeof payload.data !== "undefined") { return payload.data; } if (typeof payload.total !== "undefined" && Array.isArray(payload.data)) { return payload.data; } return payload; } function onClick(event) { if (state.isSidebarOpen) { var sidebar = event.target.closest(".dashboard-sidebar"); var toggleBtn = event.target.closest("[data-action='toggle-sidebar']"); if (!sidebar && !toggleBtn) { state.isSidebarOpen = false; document.documentElement.dataset.sidebarOpen = "false"; var sidebarEl = document.querySelector(".dashboard-sidebar"); var overlayEl = document.querySelector(".sidebar-overlay"); if (sidebarEl) sidebarEl.classList.remove("is-open"); if (overlayEl) overlayEl.classList.remove("is-visible"); } } var actionEl = event.target.closest("[data-action]"); if (!actionEl) return; var action = actionEl.getAttribute("data-action"); if (action === "switch-mode") { state.mode = actionEl.getAttribute("data-mode") || "login"; showMessage("", ""); render(); return; } if (action === "navigate") { state.isSidebarOpen = false; document.documentElement.dataset.sidebarOpen = "false"; setCurrentRoute(actionEl.getAttribute("data-route") || "overview"); return; } if (action === "toggle-sidebar") { state.isSidebarOpen = !state.isSidebarOpen; document.documentElement.dataset.sidebarOpen = String(state.isSidebarOpen); var sidebar = document.querySelector(".dashboard-sidebar"); var overlay = document.querySelector(".sidebar-overlay"); if (sidebar) sidebar.classList.toggle("is-open", state.isSidebarOpen); if (overlay) overlay.classList.toggle("is-visible", state.isSidebarOpen); return; } if (action === "close-sidebar") { state.isSidebarOpen = false; document.documentElement.dataset.sidebarOpen = "false"; var sidebar = document.querySelector(".dashboard-sidebar"); var overlay = document.querySelector(".sidebar-overlay"); if (sidebar) sidebar.classList.remove("is-open"); if (overlay) overlay.classList.remove("is-visible"); return; } if (action === "toggle-theme-mode") { toggleThemeMode(); return; } if (action === "change-node-page") { setNodePage(Number(actionEl.getAttribute("data-page") || 1)); return; } if (action === "logout") { clearToken(); clearIpv6Token(); resetDashboard(); render(); return; } if (action === "refresh-dashboard") { loadDashboard(true).then(render); return; } if (action === "copy-subscribe") { copyText((state.subscribe && state.subscribe.subscribe_url) || ""); return; } if (action === "copy-ipv6-subscribe") { copyText((state.ipv6Subscribe && state.ipv6Subscribe.subscribe_url) || ""); return; } if (action === "copy-text") { copyText(actionEl.getAttribute("data-value") || ""); return; } if (action === "reset-security") { handleResetSecurity(actionEl); return; } if (action === "reset-ipv6-security") { handleResetSecurity(actionEl, true); return; } if (action === "remove-session") { handleRemoveSession(actionEl); return; } if (action === "remove-other-sessions") { handleRemoveOtherSessions(actionEl); return; } if (action === "open-ticket-detail") { handleOpenTicketDetail(actionEl); return; } if (action === "open-knowledge-article") { handleOpenKnowledgeArticle(actionEl); return; } if (action === "close-ticket-detail") { state.selectedTicketId = null; state.selectedTicket = null; render(); } if (action === "copy-node-info") { handleCopyNodeInfo(actionEl.getAttribute("data-node-id")); return; } if (action === "set-mode") { state.mode = actionEl.getAttribute("data-mode") || "login"; render(); return; } if (action === "send-reset-code") { var form = actionEl.closest("form"); var email = form.querySelector("[name='email']").value; if (!email) { showMessage("请输入邮箱地址", "warning"); return; } actionEl.disabled = true; fetchJson("/api/v1/passport/comm/sendEmailVerify", { method: "POST", auth: false, body: { email: email } }).then(function () { showMessage("验证码已发送,请检查您的邮箱", "success"); startCountdown(actionEl, 60); }).catch(function (error) { showMessage(error.message || "获取验证码失败", "error"); actionEl.disabled = false; }); return; } } function startCountdown(button, seconds) { var originalText = "获取验证码"; button.disabled = true; var current = seconds; var timer = setInterval(function () { if (current <= 0) { clearInterval(timer); button.innerText = originalText; button.disabled = false; return; } button.innerText = current + "s"; current--; }, 1000); } async function handleCopyNodeInfo(nodeId) { var server = (state.servers || []).concat(state.ipv6Servers || []).find(function (s) { return String(s.id) === String(nodeId); }); if (!server) return; var isIpv6Node = (state.ipv6Servers || []).some(function (s) { return String(s.id) === String(nodeId); }); var sub = isIpv6Node ? state.ipv6Subscribe : state.subscribe; var subUrl = (sub && sub.subscribe_url) || ""; var targetName = (server.name || "").trim(); var cacheKey = isIpv6Node ? "ipv6CachedSubNodes" : "cachedSubNodes"; var storageKey = isIpv6Node ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__"; // Try to get link from subscription if possible if (subUrl) { try { if (!state[cacheKey]) { // Check if we have a manually pasted sub content in sessionStorage var savedSub = window.sessionStorage.getItem(storageKey); if (savedSub) { console.log("Nebula: Using session-cached " + (isIpv6Node ? "IPv6 " : "") + "subscription content"); state[cacheKey] = parseSubscriptionToLines(savedSub); } else { console.log("Nebula: Synchronizing " + (isIpv6Node ? "IPv6 " : "") + "subscription metadata..."); state[cacheKey] = await fetchSubscriptionNodes(subUrl); } } if (state[cacheKey] && state[cacheKey].length) { var link = findNodeLinkByName(state[cacheKey], targetName); if (link) { var clashYaml = linkToClash(link); showNodeInfoModal(server, clashYaml, link); return; } } } catch (error) { console.error("Nebula: Failed to parse subscription", error); // If it looks like a CORS error (TypeError: Failed to fetch) if (error instanceof TypeError) { showCorsHelpModal(subUrl, nodeId, isIpv6Node); return; } } } // Fallback to text info if link not found or subscription failed var tags = normalizeServerTags(server.tags); var info = [ "节点名称: " + (server.name || "未命名节点"), "节点类型: " + (server.type || "未知"), "节点倍率: " + (server.rate || 1) + "x", "节点状态: " + (server.is_online ? "在线" : "离线"), tags.length ? "节点标签: " + tags.join(", ") : "" ].filter(Boolean).join("\n"); copyText(info, "已复制节点基本信息(订阅链接当前不可达)"); } function showNodeInfoModal(server, clashYaml, standardLink) { var overlay = document.createElement("div"); overlay.className = "nebula-modal-overlay"; var modal = document.createElement("div"); modal.className = "nebula-modal stack"; modal.style.maxWidth = "600px"; modal.style.width = "min(540px, 100%)"; modal.innerHTML = [ '

' + escapeHtml(server.name || "节点详情") + '

', '
', '
' + '节点类型' + '' + escapeHtml(server.type || "unknown").toUpperCase() + '' + '
', '
' + '结算倍率' + '' + escapeHtml(String(server.rate || 1)) + 'x' + '
', '
', '
', '
' + escapeHtml(maskClashYaml(clashYaml)) + '
', '
', '' ].join(""); document.body.appendChild(overlay); overlay.appendChild(modal); modal.querySelector("#node-info-close").onclick = function() { document.body.removeChild(overlay); }; modal.querySelector("#node-info-copy-link").onclick = function() { copyText(standardLink, "标准配置链接已复制"); document.body.removeChild(overlay); }; modal.querySelector("#node-info-copy-clash").onclick = function() { copyText(clashYaml, "Clash 配置已复制"); document.body.removeChild(overlay); }; overlay.onclick = function(e) { if (e.target === overlay) document.body.removeChild(overlay); }; } function showCorsHelpModal(subUrl, nodeId, isIpv6) { var title = (isIpv6 ? "IPv6 " : "") + "同步订阅数据"; showNebulaModal({ title: title, content: "由于浏览器跨域 (CORS) 策略限制,无法直接从网页获取节点配置链接。请手动同步一次订阅:\n\n1. 点击下方按钮在浏览器中打开订阅链接\n2. 复制网页显示的全部内容\n3. 返回此处点击“去粘贴”并存入系统", confirmText: "打开订阅链接", cancelText: "我要手动粘贴", onConfirm: function() { window.open(subUrl, "_blank"); showMessage("订阅已在新标签页打开,请复制其内容", "info"); }, onCancel: function() { showManualPasteModal(nodeId, isIpv6); } }); } function showManualPasteModal(nodeId, isIpv6) { var overlay = document.createElement("div"); overlay.className = "nebula-modal-overlay"; var modal = document.createElement("div"); modal.className = "nebula-modal stack"; modal.style.maxWidth = "550px"; var storageKey = isIpv6 ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__"; var cacheKey = isIpv6 ? "ipv6CachedSubNodes" : "cachedSubNodes"; modal.innerHTML = [ '

粘贴 ' + (isIpv6 ? "IPv6 " : "") + '订阅内容

', '

请将你刚才复制的订阅源代码粘贴在下方。我们将为您解析并保存。

', '', '' ].join(""); document.body.appendChild(overlay); overlay.appendChild(modal); modal.querySelector("#nebula-paste-cancel").onclick = function() { document.body.removeChild(overlay); }; modal.querySelector("#nebula-paste-save").onclick = function() { var content = modal.querySelector("#nebula-sub-paste").value.trim(); if (content) { window.sessionStorage.setItem(storageKey, content); state[cacheKey] = parseSubscriptionToLines(content); document.body.removeChild(overlay); showMessage("订阅数据同步成功!", "success"); if (nodeId) handleCopyNodeInfo(nodeId); } else { showMessage("内容不能为空", "error"); } }; } async function fetchSubscriptionNodes(url) { try { var response = await fetch(url); if (!response.ok) throw new Error("Network response was not ok"); var text = await response.text(); return parseSubscriptionToLines(text); } catch (e) { throw e; // Reraise to be caught by handleCopyNodeInfo for CORS check } } function parseSubscriptionToLines(text) { var decoded = ""; try { decoded = utf8Base64Decode(text.trim()); } catch (e) { decoded = text; } return decoded.split(/\r?\n/).map(function(s) { return s.trim(); }).filter(Boolean); } function findNodeLinkByName(links, name) { if (!name) return null; var target = name.trim(); console.log("Nebula: Searching for node '" + name + "' among " + links.length + " links (Strict Match)"); for (var i = 0; i < links.length; i++) { var link = links[i].trim(); var remark = ""; if (link.indexOf("#") !== -1) { var parts = link.split("#"); var rawRemark = parts[parts.length - 1]; try { remark = decodeURIComponent(rawRemark.replace(/\+/g, "%20")); } catch (e) { remark = rawRemark; } } else if (link.indexOf("vmess://") === 0) { try { var jsonStr = utf8Base64Decode(link.slice(8)); var json = JSON.parse(jsonStr); remark = json.ps || ""; } catch (e) {} } else if (link.indexOf("name:") !== -1) { var yamlMatch = link.match(/name:\s*["']?([^"']+)["']?/); if (yamlMatch) remark = yamlMatch[1]; } // Strict exact match after trimming if ((remark || "").trim() === target) { return link; } } console.warn("Nebula: No exact match found for node '" + name + "'"); return null; } function maskClashYaml(yaml) { if (!yaml) return ""; return yaml .replace(/server:\s*.+/g, "server: **********") .replace(/password:\s*.+/g, "password: **********") .replace(/uuid:\s*.+/g, "uuid: **********") .replace(/public-key:\s*.+/g, "public-key: **********"); } function linkToClash(link) { if (!link) return ""; var remark = ""; if (link.indexOf("#") !== -1) { var parts = link.split("#"); try { remark = decodeURIComponent(parts[parts.length - 1].replace(/\+/g, "%20")); } catch (e) { remark = parts[parts.length - 1]; } } // Shadowsocks if (link.indexOf("ss://") === 0) { var body = link.split("#")[0].slice(5); var parts = body.split("@"); if (parts.length < 2) return link; var userinfo = parts[0]; var serverinfo = parts[1]; var decodedUser = ""; try { decodedUser = atob(userinfo.replace(/-/g, "+").replace(/_/g, "/")); } catch (e) { decodedUser = userinfo; } var userParts = decodedUser.split(":"); var serverParts = serverinfo.split(":"); return [ "- name: \"" + (remark || "SS Node") + "\"", " type: ss", " server: " + serverParts[0], " port: " + (serverParts[1] || 443), " cipher: " + (userParts[0] || "aes-256-gcm"), " password: " + (userParts[1] || ""), " udp: true" ].join("\n"); } // VMess if (link.indexOf("vmess://") === 0) { try { var json = JSON.parse(utf8Base64Decode(link.slice(8))); var yaml = [ "- name: \"" + (json.ps || remark || "VMess Node") + "\"", " type: vmess", " server: " + json.add, " port: " + (json.port || 443), " uuid: " + json.id, " alterId: " + (json.aid || 0), " cipher: auto", " udp: true", " tls: " + (json.tls === "tls" ? "true" : "false"), " network: " + (json.net || "tcp"), " servername: " + (json.sni || json.host || "") ]; if (json.net === "ws") { yaml.push(" ws-opts:"); yaml.push(" path: " + (json.path || "/")); if (json.host) yaml.push(" headers:"); if (json.host) yaml.push(" Host: " + json.host); } if (json.net === "grpc") { yaml.push(" grpc-opts:"); yaml.push(" grpc-service-name: " + (json.path || "")); } return yaml.join("\n"); } catch (e) { return link; } } // VLESS if (link.indexOf("vless://") === 0) { var body = link.split("#")[0].slice(8); var main = body.split("?")[0]; var query = body.split("?")[1] || ""; var parts = main.split("@"); var uuid = parts[0]; var serverParts = (parts[1] || "").split(":"); var params = {}; query.split("&").forEach(function (pair) { var p = pair.split("="); params[p[0]] = decodeURIComponent(p[1] || ""); }); var yaml = [ "- name: \"" + (remark || "VLESS Node") + "\"", " type: vless", " server: " + serverParts[0], " port: " + (parseInt(serverParts[1]) || 443), " uuid: " + uuid, " udp: true", " tls: " + (params.security !== "none" ? "true" : "false"), " skip-cert-verify: true" ]; if (params.sni) yaml.push(" servername: " + params.sni); if (params.type) yaml.push(" network: " + params.type); if (params.flow) yaml.push(" flow: " + params.flow); if (params.fp) yaml.push(" client-fingerprint: " + params.fp); if (params.security === "reality") { yaml.push(" reality-opts:"); if (params.pbk) yaml.push(" public-key: " + params.pbk); if (params.sid) yaml.push(" short-id: " + params.sid); } if (params.type === "ws") { yaml.push(" ws-opts:"); yaml.push(" path: " + (params.path || "/")); if (params.host) yaml.push(" headers:"); if (params.host) yaml.push(" Host: " + params.host); } if (params.type === "grpc") { yaml.push(" grpc-opts:"); yaml.push(" grpc-service-name: " + (params.serviceName || "")); } return yaml.join("\n"); } // Trojan if (link.indexOf("trojan://") === 0) { var body = link.split("#")[0].slice(9); var main = body.split("?")[0]; var query = body.split("?")[1] || ""; var parts = main.split("@"); var password = parts[0]; var serverParts = (parts[1] || "").split(":"); var params = {}; query.split("&").forEach(function (pair) { var p = pair.split("="); params[p[0]] = decodeURIComponent(p[1] || ""); }); var yaml = [ "- name: \"" + (remark || "Trojan Node") + "\"", " type: trojan", " server: " + serverParts[0], " port: " + (parseInt(serverParts[1]) || 443), " password: " + password, " udp: true", " sni: " + (params.sni || params.peer || ""), " skip-cert-verify: true" ]; if (params.type === "ws") { yaml.push(" ws-opts:"); yaml.push(" path: " + (params.path || "/")); if (params.host) yaml.push(" headers:"); if (params.host) yaml.push(" Host: " + params.host); } if (params.type === "grpc") { yaml.push(" grpc-opts:"); yaml.push(" grpc-service-name: " + (params.serviceName || "")); } return yaml.join("\n"); } return link; } function utf8Base64Decode(str) { try { var b64 = str.trim().replace(/-/g, "+").replace(/_/g, "/"); while (b64.length % 4 !== 0) b64 += "="; var binary = atob(b64); try { if (typeof TextDecoder !== "undefined") { var bytes = new Uint8Array(binary.length); for (var i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return new TextDecoder("utf-8").decode(bytes); } return decodeURIComponent(binary.split("").map(function (c) { return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); }).join("")); } catch (e) { return binary; } } catch (e) { throw new Error("Invalid base64 content: " + e.message); } } function onInput(event) { var target = event.target; if (target.matches("[data-action='search-servers']")) { state.serverSearch = target.value; state.nodePage = 1; render(); var input = document.querySelector("[data-action='search-servers']"); if (input) { input.focus(); input.setSelectionRange(target.value.length, target.value.length); } } } async function loadUplinkMetric() { if (state.authToken) { return; } var baseUrl = getMetricsBaseUrl(); if (!baseUrl) { return; } try { var response = await fetch(baseUrl + "/api/metrics/overview", { method: "GET", headers: { "X-Requested-With": "XMLHttpRequest" } }); var payload = await response.json(); var network = (payload && payload.network) || {}; var tx = Number(network.tx || 0); var total = tx; if (total > 0) { state.uplinkMbps = (total * 8) / 1000000; var el = document.getElementById("nebula-hero-metric-stack"); if (el) { el.innerHTML = renderUplinkMetricStack(); } else if (!state.authToken || !state.user) { render(); } } } catch (error) { state.uplinkMbps = null; } } function onSubmit(event) { var form = event.target; if (!form.matches("[data-form]")) { return; } event.preventDefault(); var formType = form.getAttribute("data-form"); var data = Object.fromEntries(new FormData(form).entries()); var submitButton = form.querySelector("[type='submit']"); if (submitButton) { submitButton.disabled = true; } if (formType === "create-ticket") { fetchJson("/api/v1/user/ticket/save", { method: "POST", body: { subject: data.subject || "", level: data.level || "0", message: data.message || "" } }).then(function () { showMessage("工单已创建", "success"); state.selectedTicketId = null; state.selectedTicket = null; return loadDashboard(true); }).then(render).catch(function (error) { showMessage(error.message || "工单创建失败", "error"); render(); }).finally(function () { if (submitButton) { submitButton.disabled = false; } }); return; } if (formType === "real-name-verification") { fetchJson("/api/v1/user/real-name-verification/submit", { method: "POST", body: { real_name: data.real_name || "", identity_no: data.identity_no || "" } }).then(function (response) { var payload = unwrap(response) || {}; var verification = payload.verification || payload; state.realNameVerification = verification; showMessage( verification && verification.status === "approved" ? "实名认证已通过。" : "认证信息已提交,请等待审核。", "success" ); return loadDashboard(true); }).then(render).catch(function (error) { showMessage(error.message || "认证提交失败", "error"); render(); }).finally(function () { if (submitButton) { submitButton.disabled = false; } }); return; } if (formType === "change-password") { if (data.new_password !== data.confirm_password) { showMessage("两次输入的新密码不一致", "error"); if (submitButton) submitButton.disabled = false; return; } fetchJson("/api/v1/user/changePassword", { method: "POST", body: { old_password: data.old_password || "", new_password: data.new_password || "" } }).then(function () { showMessage("密码已修改,建议在 IPv6 节点同步新密码", "success"); form.reset(); }).catch(function (error) { showMessage(error.message || "密码修改失败", "error"); }).finally(function () { if (submitButton) { submitButton.disabled = false; } }); return; } if (formType === "forget-password") { fetchJson("/api/v1/passport/auth/forget", { method: "POST", auth: false, body: { email: data.email, email_code: data.email_code, password: data.password } }).then(function () { showMessage("密码已重置,请使用新密码登录", "success"); state.mode = "login"; render(); }).catch(function (error) { showMessage(error.message || "重置失败", "error"); }).finally(function () { if (submitButton) { submitButton.disabled = false; } }); return; } fetchJson(formType === "register" ? "/api/v1/passport/auth/register" : "/api/v1/passport/auth/login", { method: "POST", auth: false, body: data }).then(function (response) { var payload = unwrap(response); if (!payload || !payload.auth_data) { throw new Error("Authentication succeeded but token payload is missing"); } saveToken(payload.auth_data); state.authToken = getStoredToken(); // Try IPv6 login/register if email/password available if (data.email && data.password) { var ipv6Email = data.email.replace("@", "-ipv6@"); var ipv6Action = formType === "register" ? "/api/v1/passport/auth/register" : "/api/v1/passport/auth/login"; fetchJson(ipv6Action, { method: "POST", auth: false, body: Object.assign({}, data, { email: ipv6Email }) }).then(function (ipv6Response) { var ipv6Payload = unwrap(ipv6Response); if (ipv6Payload && ipv6Payload.auth_data) { saveIpv6Token(ipv6Payload.auth_data); state.ipv6AuthToken = getStoredIpv6Token(); } }).catch(function () { // Ignore IPv6 errors as it might not be enabled yet or fail }).finally(function() { loadDashboard(true).then(render); }); } showMessage(formType === "register" ? "Account created" : "Signed in", "success"); return loadDashboard(true); }).then(render).catch(function (error) { showMessage(error.message || "Unable to continue", "error"); render(); }).finally(function () { if (submitButton) { submitButton.disabled = false; } }); } function handleResetSecurity(actionEl, isIpv6) { showNebulaModal({ title: "安全提醒", content: "确定要重置订阅令牌吗?重置令牌后,原本的订阅地址将失效,你需要重新获取并配置订阅。", confirmText: "确认重置", onConfirm: function () { actionEl.disabled = true; fetchJson("/api/v1/user/resetSecurity", { method: "GET", ipv6: !!isIpv6 }).then(function (response) { var subscribeUrl = unwrap(response); var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe; if (sub && subscribeUrl) { sub.subscribe_url = subscribeUrl; } showMessage((isIpv6 ? "IPv6 " : "") + "订阅令牌已重置", "success"); render(); }).catch(function (error) { showMessage(error.message || "重置失败", "error"); render(); }).finally(function () { actionEl.disabled = false; }); } }); } function handleRemoveSession(actionEl) { var sessionId = actionEl.getAttribute("data-session-id"); if (!sessionId) { return; } actionEl.disabled = true; fetchJson("/api/v1/user/removeActiveSession", { method: "POST", body: { session_id: sessionId } }).then(function () { showMessage("Session revoked", "success"); return loadDashboard(true); }).then(render).catch(function (error) { showMessage(error.message || "Unable to revoke session", "error"); render(); }).finally(function () { actionEl.disabled = false; }); } function handleRemoveOtherSessions(actionEl) { var sessions = ((state.sessionOverview && state.sessionOverview.sessions) || []).filter(function (session) { return !session.is_current; }); if (!sessions.length) { showMessage("No other sessions to sign out.", "success"); render(); return; } actionEl.disabled = true; Promise.all(sessions.map(function (session) { return fetchJson("/api/v1/user/removeActiveSession", { method: "POST", body: { session_id: session.id } }); })).then(function () { showMessage("Signed out all other sessions.", "success"); return loadDashboard(true); }).then(render).catch(function (error) { showMessage(error.message || "Unable to sign out other sessions", "error"); render(); }).finally(function () { actionEl.disabled = false; }); } function handleOpenTicketDetail(actionEl) { var ticketId = actionEl.getAttribute("data-ticket-id"); if (!ticketId) { return; } actionEl.disabled = true; fetchJson("/api/v1/user/ticket/fetch?id=" + encodeURIComponent(ticketId), { method: "GET" }).then(function (response) { state.selectedTicketId = ticketId; state.selectedTicket = unwrap(response); render(); }).catch(function (error) { showMessage(error.message || "工单详情加载失败", "error"); render(); }).finally(function () { actionEl.disabled = false; }); } function handleOpenKnowledgeArticle(actionEl) { var articleId = actionEl.getAttribute("data-article-id"); if (!articleId) { return; } var article = findKnowledgeArticleById(articleId); if (!article) { showMessage("Knowledge article not found", "error"); return; } showKnowledgeArticleModal(article); } var currentLayout = null; // "loading", "login", "dashboard" function render() { if (state.loading) { return; } var isLoggedIn = !!(state.authToken && state.user); var targetLayout = isLoggedIn ? "dashboard" : "login"; // If layout type changed (e.g. Login -> Dashboard), do a full shell render if (currentLayout !== targetLayout) { currentLayout = targetLayout; app.innerHTML = isLoggedIn ? renderDashboard() : renderAuth(); hideLoader(); return; } // If already in Dashboard, only update the content area and sidebar state if (isLoggedIn) { var usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0); var totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0; var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0); var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0; var stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0]; var overview = state.sessionOverview || {}; var contentArea = document.querySelector(".dashboard-main"); if (contentArea) { contentArea.innerHTML = renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview); } var sidebarContainer = document.querySelector(".dashboard-shell"); if (sidebarContainer) { var sidebar = sidebarContainer.querySelector(".dashboard-sidebar"); if (sidebar) { sidebar.innerHTML = renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, true); } } } else { // For auth page, simple re-render is fine app.innerHTML = renderAuth(); } hideLoader(); } function renderAuth() { return [ '
', renderTopbar(false), '
', '
', '
' + escapeHtml(theme.description || "") + "
", '
' + renderUplinkMetricStack() + '
', (theme.config && theme.config.slogan) ? '

' + escapeHtml(theme.config.slogan) + "

" : "", "
", '
', (theme.config && (theme.config.isRegisterEnabled || state.mode === "forget-password")) ? [ '
', tabButton("login", "登录", state.mode === "login"), (theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "注册", state.mode === "register") : "", tabButton("forget-password", "找回密码", state.mode === "forget-password"), "
" ].join("") : [ '
', tabButton("login", "登录", state.mode === "login"), tabButton("forget-password", "找回密码", state.mode === "forget-password"), "
" ].join(""), renderAuthPanelHeading(), renderAuthForm(), "
", "
", renderRecordFooter(), "
" ].join(""); } function renderDashboard() { var usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0); var totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0; var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0); var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0; var stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0]; var overview = state.sessionOverview || {}; return renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview); } function renderTopbar(isLoggedIn) { return [ '
', '
', isLoggedIn ? '' : '', renderBrandMark(), '

' + escapeHtml(theme.title || "Nebula") + "

", "
", '
', renderThemeToggle(), isLoggedIn ? '' : '', "
", "
" ].join(""); } function renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) { return [ '
', renderTopbar(true), '', '
', renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview), '
', renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview), '
', '
', renderRecordFooter(), '
' ].join(""); } function renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) { if (state.currentRoute === "access-history") { return [ '
', renderSessionSection(overview.sessions || []), renderIpSection(overview.online_ips || []), '
' ].join(""); } if (state.currentRoute === "nodes") { return [ '
', renderSubscribeSection("span-12 section-card--overview"), renderServerSection(state.servers || []), '
' ].join(""); } if (state.currentRoute === "ipv6-nodes") { return [ '
', renderIpv6SubscribeSection("span-12 section-card--overview"), renderServerSection(state.ipv6Servers || [], true), '
' ].join(""); } if (state.currentRoute === "tickets") { return [ '
', renderTicketComposer(), renderTicketSection(state.tickets || []), renderTicketDetailSection(state.selectedTicket), '
' ].join(""); } if (state.currentRoute === "real-name") { return [ '
', renderRealNameVerificationPanel(), '
' ].join(""); } if (state.currentRoute === "security") { return [ '
', renderSecuritySection(), '
' ].join(""); } var html = [ '
', renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, "span-12 section-card--overview"), renderIpv6TrafficOverviewCard("span-12"), renderAccessSnapshotCard(overview, "span-12 section-card--overview"), renderRealNameVerificationOverviewCard("span-12 section-card--overview"), '
' ].join(""); return html; } function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) { return [ '
', '
', 'Subscription', "

" + escapeHtml(theme.title || "Nebula") + "

", "

" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " · " + escapeHtml((state.user && state.user.email) || "") + "

", '
', dashboardStat(formatTraffic(remainingTraffic), "Remaining traffic"), dashboardStat(String(overview.online_ip_count || 0), "Current online IPs"), dashboardStat(String(overview.active_session_count || 0), "Stored sessions"), '
', '
', '', '
', renderSubscribeSection(), '
' ].join(""); } function renderAccessSnapshot(overview) { return [ '
', renderAccessSnapshotCard(overview), renderOpsSection(Array.isArray(state.stats) ? state.stats : [0, 0, 0], overview), '
' ].join(""); } function renderAccessSnapshotCard(overview, extraClass) { extraClass = extraClass || "span-7"; var ipv6Overview = state.ipv6SessionOverview; var limitDisplay = formatLimit(overview.device_limit) + (ipv6Overview ? " / " + formatLimit(ipv6Overview.device_limit) : ""); return [ '
', '
访问概览

安全状态

', '
', kpiBox("设备数限制(IPv4/IPv6)", limitDisplay), kpiBox("余额", formatMoney((state.user && state.user.balance) || 0, state.appConfig && state.appConfig.currency_symbol)), kpiBox("到期时间", formatDate(state.subscribe && state.subscribe.expired_at)), '
', '
' ].join(""); } function renderIpv6TrafficOverviewCard(extraClass) { if (!state.ipv6AuthToken || !state.ipv6Subscribe) { return [ '
', '
IPv6 流量

IPv6 流量概览

', '
IPv6 账号未启用或加载失败。
', '
' ].join(""); } var usedTraffic = (state.ipv6Subscribe.u || 0) + (state.ipv6Subscribe.d || 0); var totalTraffic = state.ipv6Subscribe.transfer_enable || 0; var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0); var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0; return renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, extraClass || "span-6 section-card--overview", "IPv6 "); } function renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, extraClass, prefix) { extraClass = extraClass || "span-5"; prefix = prefix || ""; return [ '
', '
流量

' + prefix + '用户流量概览

', '
', kpiBox("剩余", formatTraffic(remainingTraffic)), kpiBox("已用", formatTraffic(usedTraffic)), kpiBox("总量", formatTraffic(totalTraffic)), kpiBox("使用率", String(percent) + "%"), '
', '
', '
' ].join(""); } function renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, innerOnly) { var content = [ '' ].join(""); content = content.replace(/', '', '
', '
', '', "
", "" ].join(""); } return [ '
', '
', '
', '
', '", "
", "
" ].join(""); } function renderIpv6SubscribeSection(extraClass) { return renderSubscribeSection(extraClass, true); } function renderSubscribeSection(extraClass, isIpv6) { extraClass = extraClass || "span-7"; var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe; var user = isIpv6 ? state.ipv6User : state.user; var ipv6Eligibility = state.ipv6Eligibility || {}; var prefix = isIpv6 ? "IPv6 " : ""; var copyAction = isIpv6 ? "copy-ipv6-subscribe" : "copy-subscribe"; var resetAction = isIpv6 ? "reset-ipv6-security" : "reset-security"; if (isIpv6 && !state.ipv6AuthToken) { // IPv6 not enabled - show guidance to submit a ticket } else if (isIpv6 && !sub) { return ""; } var ipv6FooterHtml = ""; if (isIpv6 && !state.ipv6AuthToken) { var statusText = ipv6Eligibility.status_label || "未开通"; var guidanceText = "IPv6 订阅需要通过工单由管理员开通,请提交工单申请。"; if (ipv6Eligibility.is_active) { guidanceText = "您的 IPv6 已开通,如有问题请联系管理员。"; } ipv6FooterHtml = ''; } var html = [ '
', '
' + prefix + '订阅

连接工具

', '', "
", '
', !isIpv6 ? kpiBox("套餐", escapeHtml((sub && sub.plan && sub.plan.name) || "暂无套餐")) : "", kpiBox("限速", formatSpeed(sub && sub.speed_limit)), kpiBox("重置日", String((sub && sub.reset_day) || "-")), !isIpv6 ? kpiBox("邮箱", '' + escapeHtml((user && user.email) || "-") + "") : "", "
", ipv6FooterHtml, "
" ].join(""); return html; } function renderOpsSection(stats, overview) { return [ '
', '
运营

当前工作台

', '
', kpiBox("待处理订单", String(stats[0] || 0)), kpiBox("工单数量", String(stats[1] || 0)), kpiBox("邀请用户", String(stats[2] || 0)), kpiBox("在线设备", String(overview.online_device_count || 0)), "
", "
" ].join(""); } function renderIpSection(ips) { return [ '
', '
当前 IP

在线入口

', renderIpList(ips), "
" ].join(""); } function renderSessionSection(sessions) { return [ '
', '
会话

访问记录

', renderSessions(sessions), "
" ].join(""); } function renderServerSection(servers, isIpv6) { return [ '
', '
', '
' + (isIpv6 ? "IPv6 " : "") + '节点

' + (isIpv6 ? "IPv6 " : "") + '服务节点' + '

', '
', '', '
', '
', renderServers(servers), "
" ].join(""); } function renderKnowledgeSection(articles) { if (!articles.length) { return [ '
', '
知识库

知识内容

', '
暂时没有内容。
', "
" ].join(""); } // Group articles by category if possibly present, or just list them return [ '
', '
知识库

全部文章

', '
' + articles.map(function (article) { return [ '
', '
', "" + escapeHtml(article.title || "未命名文章") + "", '' + escapeHtml(truncate(article.body || article.content || "", 160)) + "", "
", article.category ? '' + escapeHtml(article.category) + '' : formatDate(article.updated_at || article.created_at), "
" ].join(""); }).join("") + "
", "
" ].join(""); } function renderTicketSection(tickets) { return [ '
', '
工单

工单列表

', renderTickets(tickets), "
" ].join(""); } function renderTicketComposer() { return [ '
', '
新建

创建工单

', '
', '
', '
', '
', '
', '
', "
" ].join(""); } function renderTicketDetailSection(ticket) { if (!ticket) { return ""; } return [ '
', '
详情

' + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + '

', '
', kpiBox("状态", renderTicketStatus(ticket.status)), kpiBox("等级", getTicketLevelLabel(ticket.level)), kpiBox("创建时间", formatDate(ticket.created_at)), kpiBox("更新时间", formatDate(ticket.updated_at)), '
', renderTicketMessages(ticket.message || []), "
" ].join(""); } function renderIpList(ips) { var list = ips.slice(); if (!list.length) { return '
当前还没有检测到在线 IP 记录。
'; } return '
' + list.map(function (ip) { return '
在线设备 IP' + escapeHtml(ip) + '
'; }).join("") + "
"; } function renderSessions(sessions) { if (!sessions.length) { return '
当前没有会话记录。
'; } return '
' + sessions.map(function (session) { var tag = session.is_current ? '当前' : '已保存'; var revoke = session.is_current ? "" : ''; return [ '
', '
', "" + escapeHtml(session.name || ("Session #" + session.id)) + "", '创建于 ' + formatDate(session.created_at) + " · 最近使用 " + formatDate(session.last_used_at) + (session.ip ? " · IP " + escapeHtml(session.ip) : "") + "", "
", '
' + tag + revoke + "
", "
" ].join(""); }).join("") + "
"; } function renderServers(servers) { if (!servers.length) { return '
当前没有可用节点。
'; } var sortedServers = servers.slice().sort(compareServers); var searchTerm = (state.serverSearch || "").toLowerCase(); var filteredServers = sortedServers; if (searchTerm) { filteredServers = sortedServers.filter(function (server) { var nameMatch = (server.name || "").toLowerCase().indexOf(searchTerm) !== -1; var tags = normalizeServerTags(server.tags); var tagMatch = tags.some(function (tag) { return String(tag).toLowerCase().indexOf(searchTerm) !== -1; }); return nameMatch || tagMatch; }); } if (!filteredServers.length && servers.length > 0) { return '
没有匹配的节点。
'; } var pageSize = getNodePageSize(); var totalPages = Math.max(1, Math.ceil(filteredServers.length / pageSize)); if (state.nodePage > totalPages) { state.nodePage = totalPages; } if (state.nodePage < 1) { state.nodePage = 1; } var startIndex = (state.nodePage - 1) * pageSize; var pagedServers = filteredServers.slice(startIndex, startIndex + pageSize); return '
' + pagedServers.map(function (server) { var tags = normalizeServerTags(server.tags); return [ '
', '
', '
', "" + escapeHtml(server.name || "未命名节点") + "", '' + escapeHtml(server.type || "node") + "", "
", '', "
", '
', nodeSpec("倍率", escapeHtml(String(server.rate || 1)) + "x"), nodeSpec("状态", '' + (server.is_online ? "在线" : "离线") + ""), "
", tags.length ? '
' + tags.map(function (tag) { return '' + escapeHtml(String(tag)) + ""; }).join("") + "
" : "", "
" ].join(""); }).join("") + '
' + renderNodePagination(totalPages) + "
"; } function renderNotices(articles) { if (!articles.length) { return '
暂时没有内容。
'; } return '
' + articles.map(function (article) { return [ '
', '
', "" + escapeHtml(article.title || "公告") + "", '' + escapeHtml(truncate(article.body || article.content || "", 160)) + "", "
", '' + formatDate(article.updated_at || article.created_at) + "", "
" ].join(""); }).join("") + "
"; } function renderTickets(tickets) { if (!tickets.length) { return '
当前没有工单记录。
'; } return '
' + tickets.map(function (ticket) { return [ '
', '
', "" + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + "", '等级 ' + escapeHtml(getTicketLevelLabel(ticket.level)) + ' · 更新时间 ' + formatDate(ticket.updated_at || ticket.created_at) + "", "
", '
' + renderTicketStatus(ticket.status) + '
', "
" ].join(""); }).join("") + "
"; } function renderTicketMessages(messages) { if (!messages.length) { return '
当前工单还没有更多消息。
'; } return '
' + messages.map(function (message) { return [ '
', '
', '' + (message.is_me ? "我" : "客服") + "", '' + formatDate(message.created_at) + "", '' + escapeHtml(message.message || "") + "", "
", "
" ].join(""); }).join("") + "
"; } function renderTicketStatus(status) { var isOpen = Number(status) === 0; return '' + (isOpen ? "处理中" : "已关闭") + ""; } function getTicketLevelLabel(level) { if (String(level) === "2") { return "高"; } if (String(level) === "1") { return "中"; } return "低"; } function metricBox(value, label) { return '
' + escapeHtml(value) + '' + escapeHtml(label) + "
"; } function nodeSpec(label, value) { return '
' + escapeHtml(label) + '' + value + "
"; } function renderNodePagination(totalPages) { if (totalPages <= 1) { return ""; } return [ '
', '', '第 ' + String(state.nodePage) + " / " + String(totalPages) + " 页", '', "
" ].join(""); } function getNodePageSize() { var width = window.innerWidth || document.documentElement.clientWidth || 1280; if (width <= 860) { return 2; } if (width <= 1180) { return 4; } return 6; } function compareServers(left, right) { var leftOnline = left && left.is_online ? 1 : 0; var rightOnline = right && right.is_online ? 1 : 0; if (leftOnline !== rightOnline) { return rightOnline - leftOnline; } var leftRate = toSortableNumber(left && left.rate, Number.POSITIVE_INFINITY); var rightRate = toSortableNumber(right && right.rate, Number.POSITIVE_INFINITY); if (leftRate !== rightRate) { return leftRate - rightRate; } var leftCheckedAt = toTimestamp(left && left.last_check_at) || 0; var rightCheckedAt = toTimestamp(right && right.last_check_at) || 0; if (leftCheckedAt !== rightCheckedAt) { return rightCheckedAt - leftCheckedAt; } return String((left && left.name) || "").localeCompare(String((right && right.name) || "")); } function normalizeServerTags(tags) { if (!tags) { return []; } if (Array.isArray(tags)) { return tags.filter(Boolean); } if (typeof tags === "string") { var normalized = tags.trim(); if (!normalized || normalized === "[]" || normalized === "null" || normalized === '""') { return []; } if ((normalized.charAt(0) === "[" && normalized.charAt(normalized.length - 1) === "]") || (normalized.charAt(0) === "{" && normalized.charAt(normalized.length - 1) === "}")) { try { var parsed = JSON.parse(normalized); if (Array.isArray(parsed)) { return parsed.map(function (tag) { return String(tag).trim(); }).filter(Boolean); } } catch (error) { normalized = normalized.replace(/^\[/, "").replace(/\]$/, ""); } } return normalized.split(/[|,\/]/).map(function (tag) { return tag.trim().replace(/^["'\[]+|["'\]]+$/g, ""); }).filter(Boolean); } return []; } function toSortableNumber(value, fallback) { var numeric = Number(value); return Number.isFinite(numeric) ? numeric : fallback; } function buildSessionOverview(list, extra, isIpv6) { var ipMap = {}; var deviceMap = {}; var lastOnlineAt = null; var normalizedSessions = list.map(function (session) { var lastUsedAt = toTimestamp(session.last_used_at); var createdAt = toTimestamp(session.created_at); var expiresAt = toTimestamp(session.expires_at); var sessionName = session.name || session.device_name || session.user_agent || ("Session #" + (session.id || "")); var sessionIp = firstNonEmpty([ session.ip, session.ip_address, session.last_used_ip, session.login_ip, session.current_ip ]); var deviceKey = firstNonEmpty([ session.device_name, session.user_agent, session.client_type, session.os, sessionName ]) || ("device-" + Math.random().toString(36).slice(2)); if (sessionIp) { ipMap[sessionIp] = true; } if (deviceKey) { deviceMap[deviceKey] = true; } if (lastUsedAt && (!lastOnlineAt || lastUsedAt > lastOnlineAt)) { lastOnlineAt = lastUsedAt; } return { id: session.id, name: sessionName, abilities: session.abilities, ip: sessionIp, user_agent: session.user_agent || "", last_used_at: lastUsedAt, created_at: createdAt, expires_at: expiresAt, is_current: Boolean(session.is_current || session.current || session.is_login || session.this_device) }; }); var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe; return { online_ip_count: Object.keys(ipMap).length, online_ips: Object.keys(ipMap), online_device_count: Object.keys(deviceMap).length, device_limit: extra.device_limit || (sub ? (sub.device_limit || (sub.plan && sub.plan.device_limit) || null) : null), last_online_at: toTimestamp(extra.last_online_at) || lastOnlineAt, active_session_count: normalizedSessions.length, sessions: normalizedSessions }; } function dashboardStat(value, label) { return '
' + escapeHtml(value) + '' + escapeHtml(label) + "
"; } function kpiBox(label, value) { return '
' + label + '' + value + "
"; } function sidebarLink(route, title, copy) { return '"; } function renderBrandMark() { var logoUrl = getThemeLogo(); if (logoUrl) { return '' + escapeHtml(theme.title || '; } return ''; } function renderThemeToggle() { var isLight = state.themeMode === "light"; return [ '' ].join(""); } function renderRecordFooter() { var records = []; if (theme.config && theme.config.icpNo) { records.push('' + escapeHtml(theme.config.icpNo) + ''); } if (theme.config && theme.config.psbNo) { records.push('' + escapeHtml(theme.config.psbNo) + ''); } if (!records.length) { return ""; } return ''; } function renderAuthPanelHeading() { return [ '
', '

Welcome To

', '

' + escapeHtml(getAuthPanelTitle()) + "

", '
' ].join(""); } function getAuthPanelTitle() { if (state.mode === "register") { return (theme.config && theme.config.registerTitle) || "Create your access."; } return (theme.config && theme.config.welcomeTarget) || (theme && theme.title) || "Your Space"; } function onRouteChange() { state.currentRoute = getCurrentRoute(); if (state.authToken && state.user) { render(); } } function setCurrentRoute(route) { var nextRoute = isValidRoute(route) ? route : "overview"; state.currentRoute = nextRoute; window.location.hash = "#/" + nextRoute; render(); } function setNodePage(page) { state.nodePage = page > 0 ? page : 1; render(); } function toggleThemeMode() { state.themePreference = state.themeMode === "light" ? "dark" : "light"; persistThemePreference(state.themePreference); applyThemeMode(); render(); } function getCurrentRoute() { var hash = String(window.location.hash || ""); var route = hash.indexOf("#/") === 0 ? hash.slice(2) : ""; return isValidRoute(route) ? route : "overview"; } function getStoredThemePreference() { var stored = ""; try { stored = window.localStorage.getItem("nebula_theme_preference") || ""; } catch (error) { stored = ""; } if (stored === "light" || stored === "dark" || stored === "system") { return stored; } return getDefaultThemePreference(); } function persistThemePreference(mode) { try { window.localStorage.setItem("nebula_theme_preference", mode); } catch (error) { return; } } function applyThemeMode() { state.themeMode = resolveThemeMode(state.themePreference); document.documentElement.dataset.themeMode = state.themeMode; document.documentElement.dataset.themePreference = state.themePreference; updateThemeColorMeta(); } function getThemeToggleLabel() { return state.themeMode === "light" ? "切换至黑夜模式" : "切换至白天模式"; } function getRealNameVerificationState() { return state.realNameVerification || { enabled: false, status: "unavailable", status_label: "Unavailable", can_submit: false, real_name: "", identity_no_masked: "", reject_reason: "", notice: "", submitted_at: null, reviewed_at: null }; } function getRealNameStatusTone(status) { if (status === "approved") { return "online"; } if (status === "rejected") { return "offline"; } if (status === "pending") { return "pending"; } return "neutral"; } function renderRealNameStatusChip(status, label) { return '' + escapeHtml(label || "未知状态") + ''; } function renderRealNameVerificationOverviewCard(extraClass) { var verification = getRealNameVerificationState(); if (!verification.enabled) { return ""; } var submitted = verification.submitted_at ? formatDate(verification.submitted_at) : "-"; var reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : "-"; var html = [ '
', '
实名认证

认证信息

', '
', kpiBox("认证状态", escapeHtml(verification.status_label || "未认证")), kpiBox("真实姓名", escapeHtml(verification.real_name || "-")), kpiBox("证件号码", escapeHtml(verification.identity_no_masked || "-")), '
', verification.reviewed_at ? '' : "", verification.reject_reason ? '
审核备注: ' + escapeHtml(verification.reject_reason) + '
' : "", '
' ].join(""); } function renderRealNameVerificationPanel() { var verification = getRealNameVerificationState(); if (!verification.enabled) { return ""; } var detailBlocks = [ '
', '
当前状态' + renderRealNameStatusChip(verification.status, verification.status_label) + '
', '
真实姓名' + escapeHtml(verification.real_name || "-") + '
', '
身份证号' + escapeHtml(verification.identity_no_masked || "-") + '
', '
提交时间' + escapeHtml(verification.submitted_at ? formatDate(verification.submitted_at) : "-") + '
', '
' ]; if (verification.reviewed_at) { detailBlocks.push(''); } if (verification.reject_reason) { detailBlocks.push('
驳回原因: ' + escapeHtml(verification.reject_reason) + '
'); } else if (verification.notice) { detailBlocks.push('
提示: ' + escapeHtml(verification.notice) + '
'); } if (!verification.can_submit) { return [ '
', '
实名认证

认证信息

', detailBlocks.join(""), '
' ].join(""); } return [ '
', '
实名认证

提交认证

', detailBlocks.join(""), '
', '
', '
', '
您的身份证号码将以加密形式存储,并在控制台中脱敏显示。
', '
', '
', '
' ].join(""); } function renderSecuritySection() { return [ '
', '
账户设置

修改密码

', '
', '
', '
', '
', '
', '', '
', '
', "
", '
', '
安全操作

订阅令牌管理

', '
', '
IPv4 订阅
', '
IPv6 订阅
', '
', '', '
' ].join(""); } function getThemeLogo() { var config = theme.config || {}; if (state.themeMode === "light") { return config.lightLogoUrl || config.darkLogoUrl || theme.logo || ""; } return config.darkLogoUrl || theme.logo || config.lightLogoUrl || ""; } function updateThemeColorMeta() { var meta = document.querySelector('meta[name="theme-color"]'); if (meta) { meta.setAttribute("content", state.themeMode === "light" ? "#eef4fb" : "#08101c"); } } function getDefaultThemePreference() { var configMode = theme && theme.config ? theme.config.defaultThemeMode : ""; if (configMode === "light" || configMode === "dark" || configMode === "system") { return configMode; } return "system"; } function resolveThemeMode(preference) { if (preference === "light" || preference === "dark") { return preference; } return isSystemDarkMode() ? "dark" : "light"; } function bindSystemThemeListener() { if (!themeMediaQuery) { return; } var onThemeChange = function () { if (state.themePreference === "system") { applyThemeMode(); render(); } }; if (typeof themeMediaQuery.addEventListener === "function") { themeMediaQuery.addEventListener("change", onThemeChange); return; } if (typeof themeMediaQuery.addListener === "function") { themeMediaQuery.addListener(onThemeChange); } } function getThemeMediaQuery() { if (!window.matchMedia) { return null; } return window.matchMedia("(prefers-color-scheme: dark)"); } function isSystemDarkMode() { return Boolean(themeMediaQuery && themeMediaQuery.matches); } function getMetricsBaseUrl() { var baseUrl = theme && theme.config ? theme.config.metricsBaseUrl : ""; return String(baseUrl || "").replace(/\/+$/, ""); } function getUplinkHeadline() { if (!Number.isFinite(state.uplinkMbps)) { return "Delivering -- Mbps"; } return "Delivering " + formatUplinkMbps(state.uplinkMbps) + " Mbps"; } function renderUplinkMetricStack() { return [ '
Delivering
', '
' + escapeHtml(getUplinkValueText()) + '
', '
Uplink Bandwidth
' ].join(""); } function getUplinkValueText() { if (!Number.isFinite(state.uplinkMbps)) { return "-- Mbps"; } return formatUplinkMbps(state.uplinkMbps) + " Mbps"; } function formatUplinkMbps(value) { if (value >= 100) { return String(Math.round(value)); } if (value >= 10) { return value.toFixed(1); } return value.toFixed(2); } function isValidRoute(route) { return [ "overview", "access-history", "nodes", "ipv6-nodes", "tickets", "real-name", "security" ].indexOf(route) !== -1; } function getRouteTitle(route) { if (route === "access-history") { return "访问记录"; } if (route === "nodes") { return "节点"; } if (route === "ipv6-nodes") { return "IPv6 节点"; } if (route === "notices") { return "知识库"; } if (route === "tickets") { return "工单"; } if (route === "real-name") { return "\u5b9e\u540d\u8ba4\u8bc1"; } if (route === "security") { return "账号安全"; } return "总览"; } function tabButton(mode, label, active) { return '"; } function getStoredToken() { var candidates = []; collectStorageValues(window.localStorage, ["access_token", "auth_data", "__nebula_auth_data__", "token", "auth_token"], candidates); collectStorageValues(window.sessionStorage, ["access_token", "auth_data", "__nebula_auth_data__", "token", "auth_token"], candidates); for (var i = 0; i < candidates.length; i += 1) { var token = extractToken(candidates[i]); if (token) { return token; } } return ""; } function saveToken(token) { var normalized = normalizeAuthHeader(token); window.localStorage.setItem("access_token", normalized); window.localStorage.setItem("auth_data", normalized); window.localStorage.setItem("__nebula_auth_data__", normalized); window.sessionStorage.setItem("access_token", normalized); window.sessionStorage.setItem("auth_data", normalized); window.sessionStorage.setItem("__nebula_auth_data__", normalized); } function clearToken() { window.localStorage.removeItem("access_token"); window.localStorage.removeItem("auth_data"); window.localStorage.removeItem("__nebula_auth_data__"); window.sessionStorage.removeItem("access_token"); window.sessionStorage.removeItem("auth_data"); window.sessionStorage.removeItem("__nebula_auth_data__"); state.authToken = ""; } function getStoredIpv6Token() { var stored = ""; try { stored = window.localStorage.getItem("__nebula_ipv6_auth_data__") || ""; } catch (e) { stored = ""; } return stored; } function saveIpv6Token(token) { window.localStorage.setItem("__nebula_ipv6_auth_data__", normalizeAuthHeader(token)); } function clearIpv6Token() { window.localStorage.removeItem("__nebula_ipv6_auth_data__"); state.ipv6AuthToken = ""; } function resetIpv6DashboardState() { clearIpv6Token(); state.ipv6User = null; state.ipv6Subscribe = null; state.ipv6Eligibility = null; state.ipv6Servers = []; state.ipv6SessionOverview = null; state.ipv6CachedSubNodes = null; window.sessionStorage.removeItem("__nebula_ipv6_sub_content__"); } function handleIpv6AuthFailure(error) { if (!error || (error.status !== 401 && error.status !== 403)) { return false; } resetIpv6DashboardState(); return true; } function resetDashboard() { state.user = null; state.subscribe = null; state.stats = null; state.knowledge = []; state.tickets = []; state.selectedTicketId = null; state.selectedTicket = null; state.servers = []; state.ipv6AuthToken = ""; state.ipv6User = null; state.ipv6Subscribe = null; state.ipv6Eligibility = null; state.ipv6Servers = []; state.sessionOverview = null; state.ipv6SessionOverview = null; state.realNameVerification = null; state.appConfig = null; state.cachedSubNodes = null; state.ipv6CachedSubNodes = null; state.loading = false; } function applyCustomBackground() { if (!theme.config || !theme.config.backgroundUrl) { document.documentElement.style.removeProperty("--nebula-bg-image"); return; } document.documentElement.style.setProperty("--nebula-bg-image", 'url("' + String(theme.config.backgroundUrl).replace(/"/g, '\\"') + '")'); } function getVerifyToken() { var url = new URL(window.location.href); var directVerify = url.searchParams.get("verify"); if (directVerify) { return directVerify; } if (window.location.hash.indexOf("?") !== -1) { return new URLSearchParams(window.location.hash.split("?")[1] || "").get("verify"); } return ""; } function clearVerifyToken() { var url = new URL(window.location.href); url.searchParams.delete("verify"); if (window.location.hash.indexOf("?") !== -1) { window.history.replaceState({}, "", url.pathname + url.search + window.location.hash.split("?")[0]); return; } window.history.replaceState({}, "", url.pathname + url.search); } function showMessage(message, type) { var container = document.getElementById("nebula-toasts"); if (!container) return; if (!message) { container.innerHTML = ""; return; } container.innerHTML = '
' + escapeHtml(message) + "
"; // Auto clear after 4s if (state.messageTimeout) clearTimeout(state.messageTimeout); state.messageTimeout = window.setTimeout(function() { container.innerHTML = ""; }, 4000); } function hideLoader() { if (!loader) { return; } loader.classList.add("is-hidden"); window.setTimeout(function () { if (loader && loader.parentNode) { loader.parentNode.removeChild(loader); loader = null; } }, 350); } function collectStorageValues(storage, keys, target) { if (!storage) { return; } for (var i = 0; i < keys.length; i += 1) { pushCandidate(storage.getItem(keys[i]), target); } } function pushCandidate(value, target) { if (value === null || typeof value === "undefined" || value === "") { return; } target.push(value); } function extractToken(value, depth) { depth = depth || 0; if (depth > 4 || value === null || typeof value === "undefined") { return ""; } if (typeof value === "string") { var trimmed = value.trim(); if (!trimmed) { return ""; } if (trimmed.indexOf("Bearer ") === 0) { return trimmed; } if (/^[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+(\.[A-Za-z0-9\-_.+/=]+)?$/.test(trimmed)) { return "Bearer " + trimmed; } if (/^[A-Za-z0-9\-_.+/=]{24,}$/.test(trimmed) && trimmed.indexOf("{") === -1) { return "Bearer " + trimmed; } if ((trimmed.charAt(0) === "{" && trimmed.charAt(trimmed.length - 1) === "}") || (trimmed.charAt(0) === "[" && trimmed.charAt(trimmed.length - 1) === "]")) { try { return extractToken(JSON.parse(trimmed), depth + 1); } catch (error) { return ""; } } return ""; } if (Array.isArray(value)) { for (var i = 0; i < value.length; i += 1) { var arrayToken = extractToken(value[i], depth + 1); if (arrayToken) { return arrayToken; } } return ""; } if (typeof value === "object") { var directKeys = ["access_token", "auth_data", "token", "Authorization", "authorization"]; for (var j = 0; j < directKeys.length; j += 1) { if (Object.prototype.hasOwnProperty.call(value, directKeys[j])) { var directToken = extractToken(value[directKeys[j]], depth + 1); if (directToken) { return directToken; } } } } return ""; } function normalizeAuthHeader(token) { var trimmed = String(token || "").trim(); if (!trimmed) { return ""; } if (trimmed.indexOf("Bearer ") === 0) { return trimmed; } return "Bearer " + trimmed; } function copyText(value, successMessage) { if (!value) { return; } navigator.clipboard.writeText(value).then(function () { showMessage(successMessage || "内容已复制到剪贴板", "success"); render(); }).catch(function () { showMessage("复制失败,请尝试手动复制", "error"); render(); }); } function formatTraffic(value) { var units = ["B", "KB", "MB", "GB", "TB"]; var size = Number(value || 0); var index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; } return size.toFixed(size >= 10 || index === 0 ? 0 : 1) + " " + units[index]; } function formatMoney(value, symbol) { return (symbol || "$") + (Number(value || 0) / 100).toFixed(2); } function formatSpeed(value) { if (!value) { return "不限"; } return value + " Mbps"; } function formatLimit(value) { if (value === null || typeof value === "undefined" || value === "") { return "不限"; } return String(value); } function formatDate(value) { var timestamp = toTimestamp(value); if (!timestamp) { return "-"; } return new Date(timestamp * 1000).toLocaleString(); } function toTimestamp(value) { if (!value) { return null; } if (typeof value === "number") { return value; } var parsed = Date.parse(value); return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000); } function truncate(text, maxLength) { var str = String(text || ""); if (str.length <= maxLength) { return str; } return str.slice(0, maxLength - 1) + "..."; } function firstNonEmpty(values) { for (var i = 0; i < values.length; i += 1) { if (values[i]) { return values[i]; } } return ""; } function escapeHtml(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function renderKnowledgeSection(articles) { if (!articles.length) { return [ '
', '
Knowledge

Knowledge Base

', '
No knowledge articles yet.
', "
" ].join(""); } return [ '
', '
Knowledge

Knowledge Base

', '
' + articles.map(function (article) { return [ '" ].join(""); }).join("") + "
", "
" ].join(""); } function getMetricsBaseUrl() { var baseUrl = theme && theme.config ? theme.config.metricsBaseUrl : ""; baseUrl = normalizeUrlValue(baseUrl); return String(baseUrl || "").replace(/\/+$/, ""); } function applyCustomBackground() { var backgroundUrl = theme.config ? normalizeUrlValue(theme.config.backgroundUrl) : ""; if (!backgroundUrl) { document.documentElement.style.removeProperty("--nebula-bg-image"); return; } document.documentElement.style.setProperty("--nebula-bg-image", 'url("' + String(backgroundUrl).replace(/"/g, '\\"') + '")'); } function getThemeLogo() { var config = theme.config || {}; var lightLogo = normalizeUrlValue(config.lightLogoUrl); var darkLogo = normalizeUrlValue(config.darkLogoUrl); var themeLogo = normalizeUrlValue(theme.logo); if (state.themeMode === "light") { return lightLogo || darkLogo || themeLogo || ""; } return darkLogo || themeLogo || lightLogo || ""; } function normalizeUrlValue(value) { var normalized = String(value == null ? "" : value).trim(); if (!normalized) { return ""; } if (normalized === '""' || normalized === "''" || normalized === "null" || normalized === "undefined") { return ""; } if ((normalized.charAt(0) === '"' && normalized.charAt(normalized.length - 1) === '"') || (normalized.charAt(0) === "'" && normalized.charAt(normalized.length - 1) === "'")) { normalized = normalized.slice(1, -1).trim(); } return normalized; } function findKnowledgeArticleById(articleId) { return (state.knowledge || []).find(function (article) { return String(article.id) === String(articleId); }) || null; } function renderKnowledgeArticleBody(article) { var content = String((article && (article.body || article.content)) || "").trim(); if (!content) { return '

No article body provided.

'; } return '
' + escapeHtml(content).replace(/\r?\n/g, "
") + '
'; } function showKnowledgeArticleModal(article) { var overlay = document.createElement("div"); overlay.className = "nebula-modal-overlay"; var modal = document.createElement("div"); modal.className = "nebula-modal nebula-modal--article"; modal.innerHTML = [ '
', 'Knowledge', '

' + escapeHtml(article.title || "Untitled article") + '

', '
', '
', article.category ? '' + escapeHtml(article.category) + '' : "", '' + escapeHtml(formatDate(article.updated_at || article.created_at)) + '', '
', renderKnowledgeArticleBody(article), '' ].join(""); document.body.appendChild(overlay); overlay.appendChild(modal); var close = function () { if (overlay.parentNode) { overlay.parentNode.removeChild(overlay); } }; var closeButton = modal.querySelector('[data-role="close-knowledge-article"]'); if (closeButton) { closeButton.onclick = close; } overlay.onclick = function (event) { if (event.target === overlay) { close(); } }; } function showNebulaModal(options) { var overlay = document.createElement("div"); overlay.className = "nebula-modal-overlay"; var modal = document.createElement("div"); modal.className = "nebula-modal"; var title = document.createElement("h3"); title.className = "nebula-modal-title"; title.innerText = options.title || "操作确认"; var body = document.createElement("div"); body.className = "nebula-modal-body"; body.innerText = options.content || ""; var footer = document.createElement("div"); footer.className = "nebula-modal-footer"; var cancelBtn = document.createElement("button"); cancelBtn.className = "btn btn-ghost"; cancelBtn.innerText = options.cancelText || "取消"; cancelBtn.onclick = function() { document.body.removeChild(overlay); if (options.onCancel) options.onCancel(); }; var confirmBtn = document.createElement("button"); confirmBtn.className = "btn btn-primary"; confirmBtn.innerText = options.confirmText || "确定提交"; confirmBtn.onclick = function() { document.body.removeChild(overlay); if (options.onConfirm) options.onConfirm(); }; footer.appendChild(cancelBtn); footer.appendChild(confirmBtn); modal.appendChild(title); modal.appendChild(body); modal.appendChild(footer); overlay.appendChild(modal); document.body.appendChild(overlay); // Auto-close on overlay click overlay.onclick = function(e) { if (e.target === overlay) { document.body.removeChild(overlay); if (options.onCancel) options.onCancel(); } }; } })();