Files
SingBox-Gopanel/frontend/theme/Nebula/assets/app.js
CN-JS-HuiBai d237d80eec
Some checks failed
build / build (api, amd64, linux) (push) Failing after -56s
build / build (api, arm64, linux) (push) Failing after -57s
build / build (api.exe, amd64, windows) (push) Failing after -57s
IPv6 订阅流程锁定
2026-04-18 10:11:31 +08:00

2976 lines
106 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 锟芥花钄拰鑴o拷锟斤拷");
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. 粴锟借澋鐓锯柍锟芥泟鍏э拷锟斤拷锟藉吀锟芥懓閳緉3. 椁堭オ滐拷鐢囨枃锟斤拷瀛电ì锟金抚铦庝亝鏂愶拷鍢ュ儙鎽潈锟借潫椁岋拷",
confirmText: "锟芥瀼锟介湀锝侊拷锟芥毠穰粬",
cancelText: "锟金革拷锟借稷啞铦庝亝鏂?,
onConfirm: function() {
window.open(subUrl, "_blank");
showMessage("闇堬絹锟芥拰鑴i妬锟藉敵锟借潙鏆糕柍锟芥瀼锟藉殫璜圭獔鎲粴锟斤拷鍡咃拷鎽帮拷", "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 闇堬絹锟芥拰鑴o拷锟借崝锟界攪锟介妬锟界憻榘?..", "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("鎾栵拷锟芥拰鑴o拷鐢囦簷锟?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("鎾屼簷锟芥拰鑴o拷鎾憋拷", "success");
state.selectedTicketId = null;
state.selectedTicket = null;
return loadDashboard(true);
}).then(render).catch(function (error) {
showMessage(error.message || "鎾屼簷锟斤拷馥暒閬f啳姊彇", "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: "铦栨锟介柆锟斤拷铦垫牚鎭ワ拷锟借獦锟斤拷锟藉殫缃革拷铦垫瑾橈拷锟斤拷鍤楋拷锟斤拷绁夛拷闇堬絹锟斤拷鍟o拷鎾狅拷浠冿拷锟斤拷闆筐3囷拷闁拷锟斤拷鍟楃巩锟界鍍庯拷婊ㄨ敪闇堬絹锟斤拷锟?,
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">',
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", "实名认证", "提交与查看实名审核状态") + '</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 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("");
}
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("");
}
"</article>"
].join("");
}
// G 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.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 || "节点") + "</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.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="切换主题">',
'<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">欢迎使用</p>',
'<h2>' + escapeHtml(getAuthPanelTitle()) + "</h2>",
'</div>'
].join("");
}
function getAuthPanelTitle() {
if (state.mode === "register") {
return (theme.config && theme.config.registerTitle) || "加入我们。";
}
return (theme.config && theme.config.welcomeTarget) || (theme && theme.title) || "您的专属空间";
}
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: "未开启",
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 reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : "-";
return [
'<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 || {};
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 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 : "";
baseUrl = normalizeUrlValue(baseUrl);
return String(baseUrl || "").replace(/\/+$/, "");
}
function getUplinkHeadline() {
if (!Number.isFinite(state.uplinkMbps)) {
return "在线带宽速率: -- Mbps";
}
return "在线带宽速率: " + formatUplinkMbps(state.uplinkMbps) + " Mbps";
}
function renderUplinkMetricStack() {
return [
'<div class="hero-metric-line">在线带宽</div>',
'<div class="hero-metric-value"><em>' + escapeHtml(getUplinkValueText()) + '</em></div>',
'<div class="hero-metric-line">实时速率详情</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 "实名认证";
}
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() {
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 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>";
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 === null || typeof value === "undefined" || 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 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">暂无文章正文内容。</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">知识库</span>',
'<h3 class="nebula-modal-title">' + escapeHtml(article.title || "未知文章") + '</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">关闭页面</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);
overlay.onclick = function(e) {
if (e.target === overlay) {
document.body.removeChild(overlay);
if (options.onCancel) options.onCancel();
}
};
}
})();
eUrl = 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();
}
};
}
})();