Files
SingBox-Gopanel/frontend/theme/Nebula/assets/app.js
CN-JS-HuiBai 8cca428d89
Some checks failed
build / build (api, amd64, linux) (push) Failing after -51s
build / build (api, arm64, linux) (push) Failing after -52s
build / build (api.exe, amd64, windows) (push) Failing after -51s
规范化UI JS CONFIG
2026-04-18 21:55:54 +08:00

2900 lines
103 KiB
JavaScript

(function () {
"use strict";
const theme = window.NEBULA_THEME || {};
const app = document.getElementById('app');
const loader = document.getElementById('nebula-loader');
const themeMediaQuery = getThemeMediaQuery();
if (!app) {
return;
}
const 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 {
const verify = getVerifyToken();
if (verify) {
const verifyResponse = await fetchJson('/api/v1/passport/auth/token2Login?verify=' + encodeURIComponent(verify), {
method: 'GET',
auth: false
});
const 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 {
const 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 {
const 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) {
const 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 {
const response = await fetchJson('/api/v1/user/user-online-devices/get-ip', { method: 'GET', ipv6: !!isIpv6 });
const ipPayload = unwrap(response) || {};
const reportedIps = ipPayload.ips || [];
if (!isIpv6) {
state.reportedIps = reportedIps;
}
const 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 {
const 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 = [
'<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) {
const body = link.split('#')[0].slice(9);
const main = body.split('?')[0];
const query = body.split('?')[1] || '';
const parts = main.split('@');
const password = parts[0];
const serverParts = (parts[1] || '').split(':');
const params = {};
query.split('&').forEach((pair) => {
const p = pair.split('=');
params[p[0]] = decodeURIComponent(p[1] || '');
});
const 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 {
let b64 = str.trim().replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4 !== 0) b64 += '=';
const binary = atob(b64);
try {
if (typeof TextDecoder !== 'undefined') {
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder('utf-8').decode(bytes);
}
return decodeURIComponent(binary.split('').map((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;
}
const baseUrl = getMetricsBaseUrl();
if (!baseUrl) {
return;
}
try {
const response = await fetch(baseUrl + '/api/metrics/overview', {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const payload = await response.json();
const network = (payload && payload.network) || {};
const tx = Number(network.tx || 0);
const total = tx;
if (total > 0) {
state.uplinkMbps = (total * 8) / 1000000;
const 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) {
const form = event.target;
if (!form.matches('[data-form]')) {
return;
}
event.preventDefault();
const formType = form.getAttribute('data-form');
const data = Object.fromEntries(new FormData(form).entries());
const 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(() => {
showMessage('工单已创建', 'success');
state.selectedTicketId = null;
state.selectedTicket = null;
return loadDashboard(true);
}).then(render).catch((error) => {
showMessage(error.message || '工单创建失败', 'error');
render();
}).finally(() => {
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((response) => {
const payload = unwrap(response) || {};
const verification = payload.verification || payload;
state.realNameVerification = verification;
showMessage(
verification && verification.status === 'approved'
? '实名认证已通过。'
: '认证信息已提交,请等待审核。',
'success'
);
return loadDashboard(true);
}).then(render).catch((error) => {
showMessage(error.message || '认证提交失败', 'error');
render();
}).finally(() => {
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(() => {
showMessage('密码已修改,建议在 IPv6 节点同步新密码', 'success');
form.reset();
}).catch((error) => {
showMessage(error.message || '密码修改失败', 'error');
}).finally(() => {
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(() => {
showMessage('密码已重置,请使用新密码登录', 'success');
state.mode = 'login';
render();
}).catch((error) => {
showMessage(error.message || '重置失败', 'error');
}).finally(() => {
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((response) => {
const 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();
showMessage(formType === 'register' ? 'Account created' : 'Signed in', 'success');
return loadDashboard(true).then(() => {
if (formType !== 'login' || !data.email || !data.password || state.ipv6AuthToken) {
return null;
}
if (!(state.ipv6Eligibility && state.ipv6Eligibility.is_active)) {
return null;
}
const ipv6Email = data.email.replace('@', '-ipv6@');
return fetchJson('/api/v1/passport/auth/login', {
method: 'POST',
auth: false,
body: Object.assign({}, data, { email: ipv6Email })
}).then((ipv6Response) => {
const ipv6Payload = unwrap(ipv6Response);
if (ipv6Payload && ipv6Payload.auth_data) {
saveIpv6Token(ipv6Payload.auth_data);
state.ipv6AuthToken = getStoredIpv6Token();
}
}).catch(() => {
return null;
}).then(() => {
return loadDashboard(true);
});
});
}).then(render).catch((error) => {
showMessage(error.message || 'Unable to continue', 'error');
render();
}).finally(() => {
if (submitButton) {
submitButton.disabled = false;
}
});
}
function handleResetSecurity(actionEl, isIpv6) {
showNebulaModal({
title: '安全提醒',
content: '确定要重置订阅令牌吗?重置令牌后,原本的订阅地址将失效,你需要重新获取并配置订阅。',
confirmText: '确认重置',
onConfirm: () => {
actionEl.disabled = true;
fetchJson('/api/v1/user/resetSecurity', { method: 'GET', ipv6: !!isIpv6 }).then((response) => {
const subscribeUrl = unwrap(response);
const sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
if (sub && subscribeUrl) {
sub.subscribe_url = subscribeUrl;
}
showMessage((isIpv6 ? 'IPv6 ' : '') + '订阅令牌已重置', 'success');
render();
}).catch((error) => {
showMessage(error.message || '重置失败', 'error');
render();
}).finally(() => {
actionEl.disabled = false;
});
}
});
}
function handleRemoveSession(actionEl) {
const sessionId = actionEl.getAttribute('data-session-id');
if (!sessionId) {
return;
}
actionEl.disabled = true;
fetchJson('/api/v1/user/removeActiveSession', {
method: 'POST',
body: { session_id: sessionId }
}).then(() => {
showMessage('Session revoked', 'success');
return loadDashboard(true);
}).then(render).catch((error) => {
showMessage(error.message || 'Unable to revoke session', 'error');
render();
}).finally(() => {
actionEl.disabled = false;
});
}
function handleRemoveOtherSessions(actionEl) {
const sessions = ((state.sessionOverview && state.sessionOverview.sessions) || []).filter((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((session) => {
return fetchJson('/api/v1/user/removeActiveSession', {
method: 'POST',
body: { session_id: session.id }
});
})).then(() => {
showMessage('Signed out all other sessions.', 'success');
return loadDashboard(true);
}).then(render).catch((error) => {
showMessage(error.message || 'Unable to sign out other sessions', 'error');
render();
}).finally(() => {
actionEl.disabled = false;
});
}
function handleOpenTicketDetail(actionEl) {
const ticketId = actionEl.getAttribute('data-ticket-id');
if (!ticketId) {
return;
}
actionEl.disabled = true;
fetchJson('/api/v1/user/ticket/fetch?id=' + encodeURIComponent(ticketId), { method: 'GET' }).then((response) => {
state.selectedTicketId = ticketId;
state.selectedTicket = unwrap(response);
render();
}).catch((error) => {
showMessage(error.message || '工单详情加载失败', 'error');
render();
}).finally(() => {
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;
}
const isLoggedIn = !!(state.authToken && state.user);
const 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) {
const usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0);
const totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0;
const remainingTraffic = Math.max(totalTraffic - usedTraffic, 0);
const percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0;
const stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0];
const overview = state.sessionOverview || {};
const contentArea = document.querySelector('.dashboard-main');
if (contentArea) {
contentArea.innerHTML = renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview);
}
const sidebarContainer = document.querySelector('.dashboard-shell');
if (sidebarContainer) {
const 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() {
const usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0);
const totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0;
const remainingTraffic = Math.max(totalTraffic - usedTraffic, 0);
const percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0;
const stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0];
const 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("");
}
const 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';
const ipv6Overview = state.ipv6SessionOverview;
const 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) {
let 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';
const sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
const user = isIpv6 ? state.ipv6User : state.user;
const ipv6Eligibility = state.ipv6Eligibility || {};
const prefix = isIpv6 ? 'IPv6 ' : '';
const copyAction = isIpv6 ? 'copy-ipv6-subscribe' : 'copy-subscribe';
const resetAction = isIpv6 ? 'reset-ipv6-security' : 'reset-security';
if (isIpv6 && !state.ipv6AuthToken) {
// IPv6 not enabled - show guidance to submit a ticket
} else if (isIpv6 && !sub) {
return '';
}
let ipv6FooterHtml = '';
if (isIpv6 && !state.ipv6AuthToken) {
const statusText = ipv6Eligibility.status_label || '未开通';
let guidanceText = 'IPv6 订阅需要通过工单由管理员开通,请提交工单申请。';
if (ipv6Eligibility.is_active) {
guidanceText = '您的 IPv6 已开通,如有问题请联系管理员。';
}
ipv6FooterHtml = '<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">' +
'<strong>' + escapeHtml(statusText) + '</strong> — ' + escapeHtml(guidanceText) +
'</div></div>';
}
const 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>',
'</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>',
ipv6FooterHtml,
'</article>'
].join('');
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) {
const 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() {
const 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') {
let 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 {
const parsed = JSON.parse(normalized);
if (Array.isArray(parsed)) {
return parsed.map((tag) => {
return String(tag).trim();
}).filter(Boolean);
}
} catch (error) {
normalized = normalized.replace(/^\[/, '').replace(/\]$/, '');
}
}
return normalized.split(/[|,\/]/).map((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) {
const ipMap = {};
const deviceMap = {};
let lastOnlineAt = null;
const normalizedSessions = list.map((session) => {
const lastUsedAt = toTimestamp(session.last_used_at);
const createdAt = toTimestamp(session.created_at);
const expiresAt = toTimestamp(session.expires_at);
const sessionName = session.name || session.device_name || session.user_agent || ('Session #' + (session.id || ''));
const sessionIp = firstNonEmpty([
session.ip,
session.ip_address,
session.last_used_ip,
session.login_ip,
session.current_ip
]);
const 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)
};
});
const 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() {
const 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() {
const 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() {
const 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) {
const 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() {
const hash = String(window.location.hash || '');
const route = hash.indexOf('#/') === 0 ? hash.slice(2) : '';
return isValidRoute(route) ? route : 'overview';
}
function getStoredThemePreference() {
let 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) {
const verification = getRealNameVerificationState();
if (!verification.enabled) {
return '';
}
const submitted = verification.submitted_at ? formatDate(verification.submitted_at) : '-';
const reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : '-';
const 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() {
const config = theme.config || {};
if (state.themeMode === 'light') {
return config.lightLogoUrl || config.darkLogoUrl || theme.logo || '';
}
return config.darkLogoUrl || theme.logo || config.lightLogoUrl || '';
}
function updateThemeColorMeta() {
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute('content', state.themeMode === 'light' ? '#eef4fb' : '#08101c');
}
}
function getDefaultThemePreference() {
const 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;
}
const onThemeChange = () => {
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() {
const 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() {
const 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 (let i = 0; i < candidates.length; i += 1) {
const token = extractToken(candidates[i]);
if (token) {
return token;
}
}
return '';
}
function saveToken(token) {
const 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() {
const url = new URL(window.location.href);
const 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() {
const 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) {
const 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(() => {
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 (let 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') {
const 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 (let i = 0; i < value.length; i += 1) {
const arrayToken = extractToken(value[i], depth + 1);
if (arrayToken) {
return arrayToken;
}
}
return '';
}
if (typeof value === 'object') {
const directKeys = ['access_token', 'auth_data', 'token', 'Authorization', 'authorization'];
for (let j = 0; j < directKeys.length; j += 1) {
if (Object.prototype.hasOwnProperty.call(value, directKeys[j])) {
const directToken = extractToken(value[directKeys[j]], depth + 1);
if (directToken) {
return directToken;
}
}
}
}
return '';
}
function normalizeAuthHeader(token) {
const 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) {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = Number(value || 0);
let 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;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000);
}
function truncate(text, maxLength) {
const str = String(text || '');
if (str.length <= maxLength) {
return str;
}
return str.slice(0, maxLength - 1) + '...';
}
function firstNonEmpty(values) {
for (let 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();
}
};
}
})();