Files
SingBox-Gopanel/frontend/theme/Nebula/assets/app.js
CN-JS-HuiBai b3435e5ef8
Some checks failed
build / build (api, amd64, linux) (push) Has been cancelled
build / build (api, arm64, linux) (push) Has been cancelled
build / build (api.exe, amd64, windows) (push) Has been cancelled
基本功能已初步完善
2026-04-17 20:41:47 +08:00

2940 lines
105 KiB
JavaScript

(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 === "enable-ipv6") {
handleEnableIpv6(actionEl);
return;
}
if (action === "sync-ipv6-password") {
handleSyncIpv6Password(actionEl);
return;
}
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 = [
'<h3 class="nebula-modal-title" style="margin-bottom: 20px;">' + escapeHtml(server.name || "节点详情") + '</h3>',
'<div class="node-specs" style="margin-bottom:24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">',
'<div class="node-spec" style="background: rgba(var(--accent-rgb), 0.05); border-color: rgba(var(--accent-rgb), 0.15); text-align: center; padding: 14px;">' +
'<span class="meta-label" style="margin-top:0; font-size:10px; opacity:0.7;">节点类型</span>' +
'<strong style="color: var(--accent); font-size: 18px; margin-top: 4px; display: block;">' + escapeHtml(server.type || "unknown").toUpperCase() + '</strong>' +
'</div>',
'<div class="node-spec" style="background: rgba(var(--accent-rgb), 0.05); border-color: rgba(var(--accent-rgb), 0.15); text-align: center; padding: 14px;">' +
'<span class="meta-label" style="margin-top:0; font-size:10px; opacity:0.7;">结算倍率</span>' +
'<strong style="color: var(--accent); font-size: 18px; margin-top: 4px; display: block;">' + escapeHtml(String(server.rate || 1)) + 'x</strong>' +
'</div>',
'</div>',
'<div class="field" style="margin-top: 8px;"><label>Clash 配置预览 (已脱敏)</label>',
'<pre class="node-info-pre" style="max-height: 180px; font-size: 11px;">' + escapeHtml(maskClashYaml(clashYaml)) + '</pre>',
'</div>',
'<div class="nebula-modal-footer" style="padding-top: 24px; display: flex; gap: 10px; flex-wrap: wrap;">',
'<button class="btn btn-primary" id="node-info-copy-link" style="flex: 1; min-width: 100px; white-space: nowrap;">标准格式</button>',
'<button class="btn btn-primary" id="node-info-copy-clash" style="flex: 1; min-width: 100px; white-space: nowrap;">Clash 格式</button>',
'<button class="btn btn-ghost" id="node-info-close" style="flex: 1; min-width: 100px; white-space: nowrap;">关闭</button>',
'</div>'
].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 = [
'<h3 class="nebula-modal-title">粘贴 ' + (isIpv6 ? "IPv6 " : "") + '订阅内容</h3>',
'<p class="hero-description" style="font-size:13px;margin-bottom:12px">请将你刚才复制的订阅源代码粘贴在下方。我们将为您解析并保存。</p>',
'<textarea id="nebula-sub-paste" class="field" style="width:100%;min-height:220px;font-size:12px;background:rgba(255,255,255,0.03);border-radius:12px;padding:12px" placeholder="粘贴此处..."></textarea>',
'<div class="nebula-modal-footer">',
'<button class="btn btn-ghost" id="nebula-paste-cancel">取消</button>',
'<button class="btn btn-primary" id="nebula-paste-save">解析并保存</button>',
'</div>'
].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 handleEnableIpv6(actionEl) {
actionEl.disabled = true;
try {
var response = await fetchJson("/api/v1/user/user-add-ipv6-subscription/enable", { method: "POST" });
var payload = unwrap(response) || {};
if (payload.auth_data) {
saveIpv6Token(payload.auth_data);
state.ipv6AuthToken = getStoredIpv6Token();
}
showMessage("IPv6 订阅已开启,正在刷新...", "success");
// Try to login to IPv6 account if possible, or just refresh dashboard
// Since we don't have the password here (state doesn't keep it),
// the user might need to sync password first or we assume it was synced during creation.
await loadDashboard(true);
render();
} catch (error) {
showMessage(error.message || "开启失败", "error");
} finally {
actionEl.disabled = false;
}
}
async function handleSyncIpv6Password(actionEl) {
actionEl.disabled = true;
try {
await fetchJson("/api/v1/user/user-add-ipv6-subscription/sync-password", { method: "POST" });
showMessage("密码已同步到 IPv6 账户", "success");
} catch (error) {
showMessage(error.message || "同步失败", "error");
} finally {
actionEl.disabled = false;
}
}
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 [
'<div class="app-shell">',
renderTopbar(false),
'<div class="auth-grid">',
'<section class="hero glass-card">',
'<div class="hero-badge-row"><span class="nebula-pill">' + escapeHtml(theme.description || "") + "</span></div>",
'<div id="nebula-hero-metric-stack" class="hero-metric-stack">' + renderUplinkMetricStack() + '</div>',
(theme.config && theme.config.slogan) ? '<p class="hero-custom-line">' + escapeHtml(theme.config.slogan) + "</p>" : "",
"</section>",
'<section class="auth-panel glass-card">',
(theme.config && (theme.config.isRegisterEnabled || state.mode === "forget-password")) ? [
'<div class="form-tabs">',
tabButton("login", "登录", state.mode === "login"),
(theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "注册", state.mode === "register") : "",
tabButton("forget-password", "找回密码", state.mode === "forget-password"),
"</div>"
].join("") : [
'<div class="form-tabs">',
tabButton("login", "登录", state.mode === "login"),
tabButton("forget-password", "找回密码", state.mode === "forget-password"),
"</div>"
].join(""),
renderAuthPanelHeading(),
renderAuthForm(),
"</section>",
"</div>",
renderRecordFooter(),
"</div>"
].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 [
'<header class="topbar">',
'<div class="brand">',
isLoggedIn ? '<button class="menu-toggle" data-action="toggle-sidebar" aria-label="Menu"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>' : '',
renderBrandMark(),
'<div><h1>' + escapeHtml(theme.title || "Nebula") + "</h1></div>",
"</div>",
'<div class="topbar-actions">',
renderThemeToggle(),
isLoggedIn
? '<button class="btn btn-secondary" data-action="refresh-dashboard">刷新</button><button class="btn btn-ghost" data-action="logout">退出登录</button>'
: '',
"</div>",
"</header>"
].join("");
}
function renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) {
return [
'<div class="app-shell">',
renderTopbar(true),
'<div class="sidebar-overlay' + (state.isSidebarOpen ? ' is-visible' : '') + '" data-action="close-sidebar"></div>',
'<section class="dashboard-shell">',
renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview),
'<div class="dashboard-main">',
renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview),
'</div>',
'</section>',
renderRecordFooter(),
'</div>'
].join("");
}
function renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) {
if (state.currentRoute === "access-history") {
return [
'<section class="dashboard-grid">',
renderSessionSection(overview.sessions || []),
renderIpSection(overview.online_ips || []),
'</section>'
].join("");
}
if (state.currentRoute === "nodes") {
return [
'<section class="dashboard-grid">',
renderSubscribeSection("span-12 section-card--overview"),
renderServerSection(state.servers || []),
'</section>'
].join("");
}
if (state.currentRoute === "ipv6-nodes") {
return [
'<section class="dashboard-grid">',
renderIpv6SubscribeSection("span-12 section-card--overview"),
renderServerSection(state.ipv6Servers || [], true),
'</section>'
].join("");
}
if (state.currentRoute === "tickets") {
return [
'<section class="dashboard-grid">',
renderTicketComposer(),
renderTicketSection(state.tickets || []),
renderTicketDetailSection(state.selectedTicket),
'</section>'
].join("");
}
if (state.currentRoute === "real-name") {
return [
'<section class="dashboard-grid">',
renderRealNameVerificationPanel(),
'</section>'
].join("");
}
if (state.currentRoute === "security") {
return [
'<section class="dashboard-grid">',
renderSecuritySection(),
'</section>'
].join("");
}
var html = [
'<section class="dashboard-grid dashboard-grid--overview">',
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"),
'</section>'
].join("");
return html;
}
function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
return [
'<section class="dashboard-hero">',
'<div class="hero glass-card">',
'<span class="nebula-pill">Subscription</span>',
"<h2>" + escapeHtml(theme.title || "Nebula") + "</h2>",
"<p>" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " · " + escapeHtml((state.user && state.user.email) || "") + "</p>",
'<div class="dashboard-stat-grid">',
dashboardStat(formatTraffic(remainingTraffic), "Remaining traffic"),
dashboardStat(String(overview.online_ip_count || 0), "Current online IPs"),
dashboardStat(String(overview.active_session_count || 0), "Stored sessions"),
'</div>',
'<div class="progress"><span style="width:' + percent + '%"></span></div>',
'<p class="footer-note">Used ' + formatTraffic(usedTraffic) + ' of ' + formatTraffic(totalTraffic) + ' this cycle.</p>',
'</div>',
renderSubscribeSection(),
'</section>'
].join("");
}
function renderAccessSnapshot(overview) {
return [
'<section class="dashboard-grid">',
renderAccessSnapshotCard(overview),
renderOpsSection(Array.isArray(state.stats) ? state.stats : [0, 0, 0], overview),
'</section>'
].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 [
'<div class="section-card glass-card ' + escapeHtml(extraClass) + '">',
'<div class="section-head"><div><span class="tiny-pill">访问概览</span><h3>安全状态</h3></div></div>',
'<div class="kpi-row">',
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)),
'</div>',
'</div>'
].join("");
}
function renderIpv6TrafficOverviewCard(extraClass) {
if (!state.ipv6AuthToken || !state.ipv6Subscribe) {
return [
'<article class="section-card glass-card ' + escapeHtml(extraClass || "span-6") + ' section-card--overview">',
'<div class="section-head"><div><span class="tiny-pill">IPv6 流量</span><h3>IPv6 流量概览</h3></div></div>',
'<div class="empty-state" style="padding:24px">IPv6 账号未启用或加载失败。</div>',
'</article>'
].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 [
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
'<div class="section-head"><div><span class="tiny-pill">流量</span><h3>' + prefix + '用户流量概览</h3></div></div>',
'<div class="kpi-row">',
kpiBox("剩余", formatTraffic(remainingTraffic)),
kpiBox("已用", formatTraffic(usedTraffic)),
kpiBox("总量", formatTraffic(totalTraffic)),
kpiBox("使用率", String(percent) + "%"),
'</div>',
'<div class="progress"><span style="width:' + percent + '%"></span></div>',
'</article>'
].join("");
}
function renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, innerOnly) {
var content = [
'<div class="sidebar-nav">',
sidebarLink("overview", "总览", "查看关键概览"),
sidebarLink("access-history", "访问记录", "查看会话与当前 IP"),
sidebarLink("nodes", "节点", "查看服务节点"),
sidebarLink("ipv6-nodes", "IPv6 节点", "查看 IPv6 服务节点"),
sidebarLink("notices", "知识库", "查看最新内容"),
sidebarLink("tickets", "工单", "查看与创建工单"),
sidebarLink("security", "账号安全", "修改密码与安全设置"),
'</div>'
].join("");
content = content.replace(/<button class="sidebar-link[^"]*" data-action="navigate" data-route="notices"[\s\S]*?<\/button>/, "");
content = content.replace(
'</div>',
sidebarLink("real-name", "\u5b9e\u540d\u8ba4\u8bc1", "\u63d0\u4ea4\u4e0e\u67e5\u770b\u5b9e\u540d\u5ba1\u6838\u72b6\u6001") + '</div>'
);
if (innerOnly) return content;
return [
'<aside class="dashboard-sidebar glass-card' + (state.isSidebarOpen ? ' is-open' : '') + '">',
content,
'</aside>'
].join("");
}
function renderAuthForm() {
var isRegister = state.mode === "register";
var isForget = state.mode === "forget-password";
if (isForget) {
return [
'<form class="stack" data-form="forget-password" style="gap:12px">',
'<div class="field"><input name="email" type="email" placeholder="邮箱地址" required /></div>',
'<div class="field">',
'<div class="input-with-button">',
'<input name="email_code" type="text" placeholder="邮箱验证码" required />',
'<button type="button" class="btn btn-secondary btn-tiny send-code-btn" data-action="send-reset-code">发送验证码</button>',
'</div></div>',
'<div class="field"><input name="password" type="password" placeholder="设置新密码" minlength="8" required /></div>',
'<div class="form-actions" style="margin-top:10px">',
'<button class="btn btn-primary" type="submit">立即重置</button>',
"</div>",
"</form>"
].join("");
}
return [
'<form class="stack" data-form="' + (isRegister ? "register" : "login") + '">',
'<div class="field"><label>Email</label><input name="email" type="email" placeholder="name@example.com" required /></div>',
'<div class="field"><label>Password</label><input name="password" type="password" placeholder="Minimum 8 characters" minlength="8" required /></div>',
'<div class="form-actions">',
'<button class="btn btn-primary" type="submit">' + (isRegister ? "创建账号" : "立即登录") + "</button>",
"</div>",
"</form>"
].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) {
// If IPv6 not enabled, we still want to show the section with an "Enable" button
// But we need to make sure we don't crash on 'sub' or 'user'
} else if (isIpv6 && !sub) {
return "";
}
var html = [
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
'<div class="section-head"><div><span class="tiny-pill">' + prefix + '订阅</span><h3>连接工具</h3></div><div class="toolbar">',
'<button class="btn btn-secondary" data-action="' + copyAction + '">复制链接</button>',
isIpv6 ? '<button class="btn btn-primary" data-action="sync-ipv6-password">同步密码</button>' : '',
"</div></div>",
'<div class="kpi-row">',
!isIpv6 ? kpiBox("套餐", escapeHtml((sub && sub.plan && sub.plan.name) || "暂无套餐")) : "",
kpiBox("限速", formatSpeed(sub && sub.speed_limit)),
kpiBox("重置日", String((sub && sub.reset_day) || "-")),
!isIpv6 ? kpiBox("邮箱", '<span style="font-family:var(--font-mono)">' + escapeHtml((user && user.email) || "-") + "</span>") : "",
"</div>",
(isIpv6 && !state.ipv6AuthToken) ? '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">开启 IPv6 订阅</button></div>' : '',
"</article>"
].join("");
if (isIpv6 && !state.ipv6AuthToken) {
if (!ipv6Eligibility.allowed) {
html = html.replace(
/<div class="card-footer" style="margin-top:16px;border-top:1px solid var\(--border-color\);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">[\s\S]*?<\/button><\/div>/,
'<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><div class="empty-state" style="padding:0;text-align:left">' + escapeHtml(ipv6Eligibility.reason || ipv6Eligibility.status_label || "Current account is not eligible for IPv6 self-service") + '</div></div>'
);
}
if (!ipv6Eligibility.is_active) {
html = html.replace(/<button class="btn btn-primary" data-action="sync-ipv6-password">[\s\S]*?<\/button>/, "");
}
}
return html;
}
function renderOpsSection(stats, overview) {
return [
'<article class="section-card glass-card span-5">',
'<div class="section-head"><div><span class="tiny-pill">运营</span><h3>当前工作台</h3></div></div>',
'<div class="kpi-row">',
kpiBox("待处理订单", String(stats[0] || 0)),
kpiBox("工单数量", String(stats[1] || 0)),
kpiBox("邀请用户", String(stats[2] || 0)),
kpiBox("在线设备", String(overview.online_device_count || 0)),
"</div>",
"</article>"
].join("");
}
function renderIpSection(ips) {
return [
'<article class="section-card glass-card span-6">',
'<div class="section-head"><div><span class="tiny-pill">当前 IP</span><h3>在线入口</h3></div></div>',
renderIpList(ips),
"</article>"
].join("");
}
function renderSessionSection(sessions) {
return [
'<article class="section-card glass-card span-6">',
'<div class="section-head"><div><span class="tiny-pill">会话</span><h3>访问记录</h3></div><div class="toolbar"><button class="btn btn-ghost" data-action="remove-other-sessions">退出其他会话</button></div></div>',
renderSessions(sessions),
"</article>"
].join("");
}
function renderServerSection(servers, isIpv6) {
return [
'<article class="section-card glass-card span-12 section-card--nodes">',
'<div class="section-head">',
'<div><span class="tiny-pill">' + (isIpv6 ? "IPv6 " : "") + '节点</span><h3>' + (isIpv6 ? "IPv6 " : "") + '服务节点' + '</h3></div>',
'<div class="toolbar">',
'<input type="text" class="node-search-input" placeholder="搜索节点..." data-action="search-servers" value="' + escapeHtml(state.serverSearch || "") + '" />',
'</div>',
'</div>',
renderServers(servers),
"</article>"
].join("");
}
function renderKnowledgeSection(articles) {
if (!articles.length) {
return [
'<article class="section-card glass-card span-6">',
'<div class="section-head"><div><span class="tiny-pill">知识库</span><h3>知识内容</h3></div></div>',
'<div class="empty-state">暂时没有内容。</div>',
"</article>"
].join("");
}
// Group articles by category if possibly present, or just list them
return [
'<article class="section-card glass-card span-12">',
'<div class="section-head"><div><span class="tiny-pill">知识库</span><h3>全部文章</h3></div></div>',
'<div class="notice-list">' + articles.map(function (article) {
return [
'<div class="notice-item">',
'<div class="notice-copy">',
"<strong>" + escapeHtml(article.title || "未命名文章") + "</strong>",
'<span class="notice-meta">' + escapeHtml(truncate(article.body || article.content || "", 160)) + "</span>",
"</div>",
article.category ? '<span class="tiny-pill">' + escapeHtml(article.category) + '</span>' : formatDate(article.updated_at || article.created_at),
"</div>"
].join("");
}).join("") + "</div>",
"</article>"
].join("");
}
function renderTicketSection(tickets) {
return [
'<article class="section-card glass-card span-7">',
'<div class="section-head"><div><span class="tiny-pill">工单</span><h3>工单列表</h3></div></div>',
renderTickets(tickets),
"</article>"
].join("");
}
function renderTicketComposer() {
return [
'<article class="section-card glass-card span-5">',
'<div class="section-head"><div><span class="tiny-pill">新建</span><h3>创建工单</h3></div></div>',
'<form class="stack" data-form="create-ticket">',
'<div class="field"><label>主题</label><input name="subject" type="text" placeholder="请输入工单主题" required /></div>',
'<div class="field"><label>等级</label><select name="level" class="ticket-select"><option value="0">低</option><option value="1">中</option><option value="2">高</option></select></div>',
'<div class="field"><label>内容</label><textarea name="message" rows="6" placeholder="请描述你遇到的问题" required></textarea></div>',
'<div class="form-actions"><button class="btn btn-primary" type="submit">提交工单</button></div>',
'</form>',
"</article>"
].join("");
}
function renderTicketDetailSection(ticket) {
if (!ticket) {
return "";
}
return [
'<article class="section-card glass-card span-12">',
'<div class="section-head"><div><span class="tiny-pill">详情</span><h3>' + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + '</h3></div><div class="toolbar"><button class="btn btn-ghost" data-action="close-ticket-detail">关闭详情</button></div></div>',
'<div class="kpi-row">',
kpiBox("状态", renderTicketStatus(ticket.status)),
kpiBox("等级", getTicketLevelLabel(ticket.level)),
kpiBox("创建时间", formatDate(ticket.created_at)),
kpiBox("更新时间", formatDate(ticket.updated_at)),
'</div>',
renderTicketMessages(ticket.message || []),
"</article>"
].join("");
}
function renderIpList(ips) {
var list = ips.slice();
if (!list.length) {
return '<div class="empty-state">当前还没有检测到在线 IP 记录。</div>';
}
return '<div class="ip-list">' + list.map(function (ip) {
return '<div class="ip-item"><span class="meta-label">在线设备 IP</span><code>' + escapeHtml(ip) + '</code></div>';
}).join("") + "</div>";
}
function renderSessions(sessions) {
if (!sessions.length) {
return '<div class="empty-state">当前没有会话记录。</div>';
}
return '<div class="session-list">' + sessions.map(function (session) {
var tag = session.is_current ? '<span class="status-chip online">当前</span>' : '<span class="status-chip">已保存</span>';
var revoke = session.is_current ? "" : '<button class="btn btn-ghost" data-action="remove-session" data-session-id="' + escapeHtml(String(session.id)) + '">撤销</button>';
return [
'<div class="session-item">',
'<div class="session-copy">',
"<strong>" + escapeHtml(session.name || ("Session #" + session.id)) + "</strong>",
'<span class="session-meta">创建于 ' + formatDate(session.created_at) + " · 最近使用 " + formatDate(session.last_used_at) + (session.ip ? " · IP " + escapeHtml(session.ip) : "") + "</span>",
"</div>",
'<div class="inline-actions">' + tag + revoke + "</div>",
"</div>"
].join("");
}).join("") + "</div>";
}
function renderServers(servers) {
if (!servers.length) {
return '<div class="empty-state">当前没有可用节点。</div>';
}
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 '<div class="empty-state">没有匹配的节点。</div>';
}
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 '<div class="node-list-wrap"><div class="node-list">' + pagedServers.map(function (server) {
var tags = normalizeServerTags(server.tags);
return [
'<div class="node-item">',
'<div class="node-head">',
'<div class="node-copy">',
"<strong>" + escapeHtml(server.name || "未命名节点") + "</strong>",
'<span class="node-meta">' + escapeHtml(server.type || "node") + "</span>",
"</div>",
'<button class="node-action-btn" data-action="copy-node-info" data-node-id="' + server.id + '">查看配置</button>',
"</div>",
'<div class="node-specs">',
nodeSpec("倍率", escapeHtml(String(server.rate || 1)) + "x"),
nodeSpec("状态", '<span class="status-chip ' + (server.is_online ? "online" : "offline") + '">' + (server.is_online ? "在线" : "离线") + "</span>"),
"</div>",
tags.length ? '<div class="node-tags">' + tags.map(function (tag) {
return '<span class="tiny-pill">' + escapeHtml(String(tag)) + "</span>";
}).join("") + "</div>" : "",
"</div>"
].join("");
}).join("") + '</div>' + renderNodePagination(totalPages) + "</div>";
}
function renderNotices(articles) {
if (!articles.length) {
return '<div class="empty-state">暂时没有内容。</div>';
}
return '<div class="notice-list">' + articles.map(function (article) {
return [
'<div class="notice-item">',
'<div class="notice-copy">',
"<strong>" + escapeHtml(article.title || "公告") + "</strong>",
'<span class="notice-meta">' + escapeHtml(truncate(article.body || article.content || "", 160)) + "</span>",
"</div>",
'<span class="tiny-pill">' + formatDate(article.updated_at || article.created_at) + "</span>",
"</div>"
].join("");
}).join("") + "</div>";
}
function renderTickets(tickets) {
if (!tickets.length) {
return '<div class="empty-state">当前没有工单记录。</div>';
}
return '<div class="notice-list">' + tickets.map(function (ticket) {
return [
'<div class="notice-item">',
'<div class="notice-copy">',
"<strong>" + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + "</strong>",
'<span class="notice-meta">等级 ' + escapeHtml(getTicketLevelLabel(ticket.level)) + ' · 更新时间 ' + formatDate(ticket.updated_at || ticket.created_at) + "</span>",
"</div>",
'<div class="inline-actions">' + renderTicketStatus(ticket.status) + '<button class="btn btn-ghost" data-action="open-ticket-detail" data-ticket-id="' + escapeHtml(String(ticket.id)) + '">查看详情</button></div>',
"</div>"
].join("");
}).join("") + "</div>";
}
function renderTicketMessages(messages) {
if (!messages.length) {
return '<div class="empty-state">当前工单还没有更多消息。</div>';
}
return '<div class="session-list">' + messages.map(function (message) {
return [
'<div class="session-item">',
'<div class="session-copy">',
'<strong>' + (message.is_me ? "我" : "客服") + "</strong>",
'<span class="session-meta">' + formatDate(message.created_at) + "</span>",
'<span class="notice-meta">' + escapeHtml(message.message || "") + "</span>",
"</div>",
"</div>"
].join("");
}).join("") + "</div>";
}
function renderTicketStatus(status) {
var isOpen = Number(status) === 0;
return '<span class="status-chip ' + (isOpen ? "online" : "offline") + '">' + (isOpen ? "处理中" : "已关闭") + "</span>";
}
function getTicketLevelLabel(level) {
if (String(level) === "2") {
return "高";
}
if (String(level) === "1") {
return "中";
}
return "低";
}
function metricBox(value, label) {
return '<div class="metric-box"><span class="value">' + escapeHtml(value) + '</span><span class="label">' + escapeHtml(label) + "</span></div>";
}
function nodeSpec(label, value) {
return '<div class="node-spec"><span class="meta-label">' + escapeHtml(label) + '</span><strong>' + value + "</strong></div>";
}
function renderNodePagination(totalPages) {
if (totalPages <= 1) {
return "";
}
return [
'<div class="node-pagination">',
'<button class="btn btn-ghost" data-action="change-node-page" data-page="' + String(Math.max(1, state.nodePage - 1)) + '"' + (state.nodePage <= 1 ? " disabled" : "") + '>上一页</button>',
'<span class="tiny-pill">第 ' + String(state.nodePage) + " / " + String(totalPages) + " 页</span>",
'<button class="btn btn-ghost" data-action="change-node-page" data-page="' + String(Math.min(totalPages, state.nodePage + 1)) + '"' + (state.nodePage >= totalPages ? " disabled" : "") + '>下一页</button>',
"</div>"
].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 '<div class="dashboard-stat"><span class="value">' + escapeHtml(value) + '</span><span class="label">' + escapeHtml(label) + "</span></div>";
}
function kpiBox(label, value) {
return '<div class="kpi-box"><span class="kpi-label">' + label + '</span><span class="kpi-value">' + value + "</span></div>";
}
function sidebarLink(route, title, copy) {
return '<button class="sidebar-link' + (state.currentRoute === route ? ' is-active' : '') + '" data-action="navigate" data-route="' + escapeHtml(route) + '"><strong>' + escapeHtml(title) + '</strong><span>' + escapeHtml(copy) + "</span></button>";
}
function renderBrandMark() {
var logoUrl = getThemeLogo();
if (logoUrl) {
return '<span class="brand-mark brand-mark--image"><img src="' + escapeHtml(logoUrl) + '" alt="' + escapeHtml(theme.title || "Nebula") + ' logo" /></span>';
}
return '<span class="brand-mark" aria-hidden="true"></span>';
}
function renderThemeToggle() {
var isLight = state.themeMode === "light";
return [
'<button class="theme-switch' + (isLight ? ' is-light' : ' is-dark') + '" data-action="toggle-theme-mode" type="button" aria-label="Toggle Theme">',
'<span class="theme-switch__track">',
'<span class="theme-switch__icon theme-switch__icon--moon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></span>',
'<span class="theme-switch__icon theme-switch__icon--sun"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></span>',
'<span class="theme-switch__thumb"></span>',
'</span>',
'</button>'
].join("");
}
function renderRecordFooter() {
var records = [];
if (theme.config && theme.config.icpNo) {
records.push('<span>' + escapeHtml(theme.config.icpNo) + '</span>');
}
if (theme.config && theme.config.psbNo) {
records.push('<span>' + escapeHtml(theme.config.psbNo) + '</span>');
}
if (!records.length) {
return "";
}
return '<footer class="record-footer">' + records.join('<span class="record-divider">|</span>') + '</footer>';
}
function renderAuthPanelHeading() {
return [
'<div class="auth-panel-heading">',
'<p class="auth-panel-eyebrow">Welcome To</p>',
'<h2>' + escapeHtml(getAuthPanelTitle()) + "</h2>",
'</div>'
].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 '<span class="status-chip ' + getRealNameStatusTone(status) + '">' + escapeHtml(label || "未知状态") + '</span>';
}
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 = [
'<article class="section-card glass-card ' + escapeHtml(extraClass || "span-12") + '">',
'<div class="section-head"><div><span class="tiny-pill">实名认证</span><h3>认证信息</h3></div></div>',
'<div class="kpi-row">',
kpiBox("认证状态", escapeHtml(verification.status_label || "未认证")),
kpiBox("真实姓名", escapeHtml(verification.real_name || "-")),
kpiBox("证件号码", escapeHtml(verification.identity_no_masked || "-")),
'</div>',
verification.reviewed_at ? '<div class="footer-note" style="margin-top:16px;">审核于 ' + escapeHtml(reviewed) + '</div>' : "",
verification.reject_reason ? '<div class="real-name-alert real-name-alert--rejected">审核备注: ' + escapeHtml(verification.reject_reason) + '</div>' : "",
'</article>'
].join("");
}
function renderRealNameVerificationPanel() {
var verification = getRealNameVerificationState();
if (!verification.enabled) {
return "";
}
var detailBlocks = [
'<div class="kpi-row">',
'<div class="kpi-box"><span class="kpi-label">当前状态</span><span class="kpi-value">' + renderRealNameStatusChip(verification.status, verification.status_label) + '</span></div>',
'<div class="kpi-box"><span class="kpi-label">真实姓名</span><span class="kpi-value">' + escapeHtml(verification.real_name || "-") + '</span></div>',
'<div class="kpi-box"><span class="kpi-label">身份证号</span><span class="kpi-value">' + escapeHtml(verification.identity_no_masked || "-") + '</span></div>',
'<div class="kpi-box"><span class="kpi-label">提交时间</span><span class="kpi-value">' + escapeHtml(verification.submitted_at ? formatDate(verification.submitted_at) : "-") + '</span></div>',
'</div>'
];
if (verification.reviewed_at) {
detailBlocks.push('<div class="footer-note" style="margin-top:16px;">审核于 ' + escapeHtml(formatDate(verification.reviewed_at)) + '</div>');
}
if (verification.reject_reason) {
detailBlocks.push('<div class="real-name-alert real-name-alert--rejected">驳回原因: ' + escapeHtml(verification.reject_reason) + '</div>');
} else if (verification.notice) {
detailBlocks.push('<div class="real-name-alert">提示: ' + escapeHtml(verification.notice) + '</div>');
}
if (!verification.can_submit) {
return [
'<article class="section-card glass-card span-12">',
'<div class="section-head"><div><span class="tiny-pill">实名认证</span><h3>认证信息</h3></div></div>',
detailBlocks.join(""),
'</article>'
].join("");
}
return [
'<article class="section-card glass-card span-12">',
'<div class="section-head"><div><span class="tiny-pill">实名认证</span><h3>提交认证</h3></div></div>',
detailBlocks.join(""),
'<form class="stack" data-form="real-name-verification" style="margin-top:24px; max-width: 560px;">',
'<div class="field"><label>真实姓名</label><input name="real_name" type="text" placeholder="请输入您的法律姓名" value="' + escapeHtml(verification.real_name || "") + '" required /></div>',
'<div class="field"><label>身份证号</label><input name="identity_no" type="text" placeholder="请输入 18 位身份证号码" maxlength="18" required /></div>',
'<div class="field-help">您的身份证号码将以加密形式存储,并在控制台中脱敏显示。</div>',
'<div class="form-actions" style="margin-top:12px;"><button class="btn btn-primary" type="submit">' + (verification.status === "rejected" ? "重新提交认证" : "提交认证") + '</button></div>',
'</form>',
'</article>'
].join("");
}
function renderSecuritySection() {
return [
'<article class="section-card glass-card span-12">',
'<div class="section-head"><div><span class="tiny-pill">账户设置</span><h3>修改密码</h3></div></div>',
'<form class="stack" data-form="change-password" style="margin-top:24px; max-width: 480px;">',
'<div class="field"><label>旧密码</label><input name="old_password" type="password" placeholder="请输入当前使用的密码" required /></div>',
'<div class="field"><label>新密码</label><input name="new_password" type="password" placeholder="请输入 8 位以上新密码" minlength="8" required /></div>',
'<div class="field"><label>确认新密码</label><input name="confirm_password" type="password" placeholder="请再次输入新密码" minlength="8" required /></div>',
'<div class="form-actions" style="margin-top:12px;">',
'<button class="btn btn-primary" type="submit">立即修改</button>',
'</div>',
'</form>',
"</article>",
'<article class="section-card glass-card span-12" style="margin-top:24px;">',
'<div class="section-head"><div><span class="tiny-pill">安全操作</span><h3>订阅令牌管理</h3></div></div>',
'<div class="kpi-row" style="margin-top:20px;">',
'<div class="kpi-box"><span class="kpi-label">IPv4 订阅</span><div class="toolbar" style="margin-top:12px;"><button class="btn btn-secondary" data-action="reset-security">重置令牌</button></div></div>',
'<div class="kpi-box"><span class="kpi-label">IPv6 订阅</span><div class="toolbar" style="margin-top:12px;"><button class="btn btn-secondary" data-action="reset-ipv6-security">重置令牌</button></div></div>',
'</div>',
'<div class="footer-note" style="margin-top:16px;">提示:重置令牌后原本的订阅链接将立即失效,您需要重新在连接工具卡片中复制新链接。</div>',
'</article>'
].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 [
'<div class="hero-metric-line">Delivering</div>',
'<div class="hero-metric-value"><em>' + escapeHtml(getUplinkValueText()) + '</em></div>',
'<div class="hero-metric-line">Uplink Bandwidth</div>'
].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 '<button class="form-tab ' + (active ? "active" : "") + '" data-action="switch-mode" data-mode="' + mode + '">' + label + "</button>";
}
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 = '<div class="nebula-toast-container"><div class="message-banner ' + escapeHtml(type || "") + '">' + escapeHtml(message) + "</div></div>";
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function renderKnowledgeSection(articles) {
if (!articles.length) {
return [
'<article class="section-card glass-card span-6">',
'<div class="section-head"><div><span class="tiny-pill">Knowledge</span><h3>Knowledge Base</h3></div></div>',
'<div class="empty-state">No knowledge articles yet.</div>',
"</article>"
].join("");
}
return [
'<article class="section-card glass-card span-12">',
'<div class="section-head"><div><span class="tiny-pill">Knowledge</span><h3>Knowledge Base</h3></div></div>',
'<div class="notice-list">' + articles.map(function (article) {
return [
'<button class="notice-item notice-item--interactive" type="button" data-action="open-knowledge-article" data-article-id="' + escapeHtml(String(article.id || "")) + '">',
'<div class="notice-copy">',
'<div class="notice-title-row"><span class="tiny-pill">Knowledge</span><strong>' + escapeHtml(article.title || "Untitled article") + '</strong></div>',
'<span class="notice-meta">' + escapeHtml(truncate(article.body || article.content || "", 160)) + "</span>",
"</div>",
'<div class="inline-actions">' + (article.category ? '<span class="tiny-pill">' + escapeHtml(article.category) + '</span>' : '<span class="tiny-pill">' + escapeHtml(formatDate(article.updated_at || article.created_at)) + '</span>') + '<span class="notice-open-indicator">Open</span></div>',
"</button>"
].join("");
}).join("") + "</div>",
"</article>"
].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 '<p class="knowledge-article-empty">No article body provided.</p>';
}
return '<div class="knowledge-article-body">' + escapeHtml(content).replace(/\r?\n/g, "<br>") + '</div>';
}
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 = [
'<div class="knowledge-article-head">',
'<span class="tiny-pill">Knowledge</span>',
'<h3 class="nebula-modal-title">' + escapeHtml(article.title || "Untitled article") + '</h3>',
'</div>',
'<div class="knowledge-article-meta">',
article.category ? '<span class="tiny-pill">' + escapeHtml(article.category) + '</span>' : "",
'<span>' + escapeHtml(formatDate(article.updated_at || article.created_at)) + '</span>',
'</div>',
renderKnowledgeArticleBody(article),
'<div class="nebula-modal-footer">',
'<button class="btn btn-ghost" type="button" data-role="close-knowledge-article">Close</button>',
'</div>'
].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();
}
};
}
})();