(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(maskClashYaml(clashYaml)) + '', '
请将你刚才复制的订阅源代码粘贴在下方。我们将为您解析并保存。
', '', '' ].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 [ '' + escapeHtml(theme.config.slogan) + "
" : "", "" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " · " + escapeHtml((state.user && state.user.email) || "") + "
", '