2943 lines
109 KiB
Plaintext
2943 lines
109 KiB
Plaintext
(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 renderSubscribeSection(extraClass, isIpv6) {
|
||
extraClass = extraClass || "span-7";
|
||
var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
|
||
var user = isIpv6 ? state.ipv6User : state.user;
|
||
var ipv6Eligibility = state.ipv6Eligibility || {};
|
||
var prefix = isIpv6 ? "IPv6 " : "";
|
||
var copyAction = isIpv6 ? "copy-ipv6-subscribe" : "copy-subscribe";
|
||
|
||
if (isIpv6 && !state.ipv6AuthToken) {
|
||
} else if (isIpv6 && !sub) {
|
||
return "";
|
||
}
|
||
|
||
var html = [
|
||
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
|
||
'<div class="section-head"><div><span class="tiny-pill">' + prefix + '璁㈤槄</span><h3>杩炴帴宸ュ叿</h3></div><div class="toolbar">',
|
||
'<button class="btn btn-secondary" data-action="' + copyAction + '">澶嶅埗閾炬帴</button>',
|
||
isIpv6 ? '<button class="btn btn-primary" data-action="sync-ipv6-password">鍚屾瀵嗙爜</button>' : '',
|
||
"</div></div>",
|
||
'<div class="kpi-row">',
|
||
!isIpv6 ? kpiBox("濂楅", escapeHtml((sub && sub.plan && sub.plan.name) || "鏆傛棤濂楅")) : "",
|
||
kpiBox("闄愰€?, formatSpeed(sub && sub.speed_limit)),
|
||
kpiBox("閲嶇疆鏃?, String((sub && sub.reset_day) || "-")),
|
||
!isIpv6 ? kpiBox("閭", '<span style="font-family:var(--font-mono)">' + escapeHtml((user && user.email) || "-") + "</span>") : "",
|
||
"</div>",
|
||
(isIpv6 && !state.ipv6AuthToken) ? '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><button class="btn btn-secondary btn-block" disabled>璇峰湪宸ュ崟涓仈绯荤鐞嗗憳寮€鍚?IPv6 璁㈤槄</button></div>' : '',
|
||
"</article>"
|
||
].join("");
|
||
if (isIpv6 && !state.ipv6AuthToken) {
|
||
if (!ipv6Eligibility.allowed) {
|
||
html = html.replace(
|
||
/<div class="card-footer" style="margin-top:16px;border-top:1px solid var\(--border-color\);padding-top:16px"><button class="btn btn-primary btn-block" data-action="enable-ipv6">[\s\S]*?<\/button><\/div>/,
|
||
'<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px"><div class="empty-state" style="padding:0;text-align:left">' + escapeHtml(ipv6Eligibility.reason || ipv6Eligibility.status_label || "Current account is not eligible for IPv6 self-service") + '</div></div>'
|
||
);
|
||
}
|
||
if (!ipv6Eligibility.is_active) {
|
||
html = html.replace(/<button class="btn btn-primary" data-action="sync-ipv6-password">[\s\S]*?<\/button>/, "");
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function renderOpsSection(stats, overview) {
|
||
return [
|
||
'<article class="section-card glass-card span-5">',
|
||
'<div class="section-head"><div><span class="tiny-pill">椁堥灡皎</span><h3>鏁舵瀼锟芥拰涔╋拷锟斤拷</h3></div></div>',
|
||
'<div class="kpi-row">',
|
||
kpiBox("鏁猴拷锟斤拷锟芥仴锟斤拷", String(stats[0] || 0)),
|
||
kpiBox("鎾屼簷锟斤拷鍦堬拷", String(stats[1] || 0)),
|
||
kpiBox("锟斤拷闇傜憺閸傦拷锟?, String(stats[2] || 0)),
|
||
kpiBox("锟藉嚱鐟湀鏇夛拷", String(overview.online_device_count || 0)),
|
||
"</div>",
|
||
"</article>"
|
||
].join("");
|
||
}
|
||
|
||
function renderIpSection(ips) {
|
||
return [
|
||
'<article class="section-card glass-card span-6">',
|
||
'<div class="section-head"><div><span class="tiny-pill">鏁舵瀼锟?IP</span><h3>锟藉嚱鐟拷浜欒梺</h3></div></div>',
|
||
renderIpList(ips),
|
||
"</article>"
|
||
].join("");
|
||
}
|
||
|
||
function renderSessionSection(sessions) {
|
||
return [
|
||
'<article class="section-card glass-card span-6">',
|
||
'<div class="section-head"><div><span class="tiny-pill">闅℃湠锟?/span><h3>闇堣几鋽湀鍟o拷</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">鏁舵瀼锟界槤鈯ワ拷闅℃湠锟介湀鍟o拷锟斤拷</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">鏁舵瀼锟界槤鈯ワ拷鎾屼簷锟介湀鍟o拷锟斤拷</div>';
|
||
}
|
||
return '<div class="notice-list">' + tickets.map(function (ticket) {
|
||
return [
|
||
'<div class="notice-item">',
|
||
'<div class="notice-copy">',
|
||
"<strong>" + escapeHtml(ticket.subject || ("鎾屼簷锟?#" + ticket.id)) + "</strong>",
|
||
'<span class="notice-meta">铦戝帺婕?' + escapeHtml(getTicketLevelLabel(ticket.level)) + ' 绻?锟芥箶榘碉拷鍦掟、?' + formatDate(ticket.updated_at || ticket.created_at) + "</span>",
|
||
"</div>",
|
||
'<div class="inline-actions">' + renderTicketStatus(ticket.status) + '<button class="btn btn-ghost" data-action="open-ticket-detail" data-ticket-id="' + escapeHtml(String(ticket.id)) + '">锟戒害锟介渹琛岋拷</button></div>',
|
||
"</div>"
|
||
].join("");
|
||
}).join("") + "</div>";
|
||
}
|
||
|
||
function renderTicketMessages(messages) {
|
||
if (!messages.length) {
|
||
return '<div class="empty-state">鏁舵瀼锟芥拰浜欙拷椁堜€圭摲锟姐棁鍑掓啳稹亸锟斤拷鑳拷锟?/div>';
|
||
}
|
||
return '<div class="session-list">' + messages.map(function (message) {
|
||
return [
|
||
'<div class="session-item">',
|
||
'<div class="session-copy">',
|
||
'<strong>' + (message.is_me ? "锟斤拷" : "鎽帮几锟?) + "</strong>",
|
||
'<span class="session-meta">' + formatDate(message.created_at) + "</span>",
|
||
'<span class="notice-meta">' + escapeHtml(message.message || "") + "</span>",
|
||
"</div>",
|
||
"</div>"
|
||
].join("");
|
||
}).join("") + "</div>";
|
||
}
|
||
|
||
function renderTicketStatus(status) {
|
||
var isOpen = Number(status) === 0;
|
||
return '<span class="status-chip ' + (isOpen ? "online" : "offline") + '">' + (isOpen ? "鎲拷锟介姖锟? : "鎾岃劊锟斤拷锟?) + "</span>";
|
||
}
|
||
|
||
function getTicketLevelLabel(level) {
|
||
if (String(level) === "2") {
|
||
return "鎿冿拷";
|
||
}
|
||
if (String(level) === "1") {
|
||
return "閵濓拷";
|
||
}
|
||
return "闆匡拷";
|
||
}
|
||
|
||
function metricBox(value, label) {
|
||
return '<div class="metric-box"><span class="value">' + escapeHtml(value) + '</span><span class="label">' + escapeHtml(label) + "</span></div>";
|
||
}
|
||
|
||
function nodeSpec(label, value) {
|
||
return '<div class="node-spec"><span class="meta-label">' + escapeHtml(label) + '</span><strong>' + value + "</strong></div>";
|
||
}
|
||
|
||
function renderNodePagination(totalPages) {
|
||
if (totalPages <= 1) {
|
||
return "";
|
||
}
|
||
return [
|
||
'<div class="node-pagination">',
|
||
'<button class="btn btn-ghost" data-action="change-node-page" data-page="' + String(Math.max(1, state.nodePage - 1)) + '"' + (state.nodePage <= 1 ? " disabled" : "") + '>閵濔牴猴拷鎲匡拷</button>',
|
||
'<span class="tiny-pill">铦氾拷 ' + String(state.nodePage) + " / " + String(totalPages) + " 鎲匡拷</span>",
|
||
'<button class="btn btn-ghost" data-action="change-node-page" data-page="' + String(Math.min(totalPages, state.nodePage + 1)) + '"' + (state.nodePage >= totalPages ? " disabled" : "") + '>閵濊·锟芥喛锟?/button>',
|
||
"</div>"
|
||
].join("");
|
||
}
|
||
|
||
function getNodePageSize() {
|
||
var width = window.innerWidth || document.documentElement.clientWidth || 1280;
|
||
if (width <= 860) {
|
||
return 2;
|
||
}
|
||
if (width <= 1180) {
|
||
return 4;
|
||
}
|
||
return 6;
|
||
}
|
||
|
||
function compareServers(left, right) {
|
||
var leftOnline = left && left.is_online ? 1 : 0;
|
||
var rightOnline = right && right.is_online ? 1 : 0;
|
||
if (leftOnline !== rightOnline) {
|
||
return rightOnline - leftOnline;
|
||
}
|
||
|
||
var leftRate = toSortableNumber(left && left.rate, Number.POSITIVE_INFINITY);
|
||
var rightRate = toSortableNumber(right && right.rate, Number.POSITIVE_INFINITY);
|
||
if (leftRate !== rightRate) {
|
||
return leftRate - rightRate;
|
||
}
|
||
|
||
var leftCheckedAt = toTimestamp(left && left.last_check_at) || 0;
|
||
var rightCheckedAt = toTimestamp(right && right.last_check_at) || 0;
|
||
if (leftCheckedAt !== rightCheckedAt) {
|
||
return rightCheckedAt - leftCheckedAt;
|
||
}
|
||
|
||
return String((left && left.name) || "").localeCompare(String((right && right.name) || ""));
|
||
}
|
||
|
||
function normalizeServerTags(tags) {
|
||
if (!tags) {
|
||
return [];
|
||
}
|
||
if (Array.isArray(tags)) {
|
||
return tags.filter(Boolean);
|
||
}
|
||
if (typeof tags === "string") {
|
||
var normalized = tags.trim();
|
||
if (!normalized || normalized === "[]" || normalized === "null" || normalized === '""') {
|
||
return [];
|
||
}
|
||
if ((normalized.charAt(0) === "[" && normalized.charAt(normalized.length - 1) === "]") ||
|
||
(normalized.charAt(0) === "{" && normalized.charAt(normalized.length - 1) === "}")) {
|
||
try {
|
||
var parsed = JSON.parse(normalized);
|
||
if (Array.isArray(parsed)) {
|
||
return parsed.map(function (tag) {
|
||
return String(tag).trim();
|
||
}).filter(Boolean);
|
||
}
|
||
} catch (error) {
|
||
normalized = normalized.replace(/^\[/, "").replace(/\]$/, "");
|
||
}
|
||
}
|
||
return normalized.split(/[|,\/]/).map(function (tag) {
|
||
return tag.trim().replace(/^["'\[]+|["'\]]+$/g, "");
|
||
}).filter(Boolean);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function toSortableNumber(value, fallback) {
|
||
var numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric : fallback;
|
||
}
|
||
|
||
function buildSessionOverview(list, extra, isIpv6) {
|
||
var ipMap = {};
|
||
var deviceMap = {};
|
||
var lastOnlineAt = null;
|
||
|
||
var normalizedSessions = list.map(function (session) {
|
||
var lastUsedAt = toTimestamp(session.last_used_at);
|
||
var createdAt = toTimestamp(session.created_at);
|
||
var expiresAt = toTimestamp(session.expires_at);
|
||
var sessionName = session.name || session.device_name || session.user_agent || ("Session #" + (session.id || ""));
|
||
var sessionIp = firstNonEmpty([
|
||
session.ip,
|
||
session.ip_address,
|
||
session.last_used_ip,
|
||
session.login_ip,
|
||
session.current_ip
|
||
]);
|
||
var deviceKey = firstNonEmpty([
|
||
session.device_name,
|
||
session.user_agent,
|
||
session.client_type,
|
||
session.os,
|
||
sessionName
|
||
]) || ("device-" + Math.random().toString(36).slice(2));
|
||
|
||
if (sessionIp) {
|
||
ipMap[sessionIp] = true;
|
||
}
|
||
if (deviceKey) {
|
||
deviceMap[deviceKey] = true;
|
||
}
|
||
if (lastUsedAt && (!lastOnlineAt || lastUsedAt > lastOnlineAt)) {
|
||
lastOnlineAt = lastUsedAt;
|
||
}
|
||
|
||
return {
|
||
id: session.id,
|
||
name: sessionName,
|
||
abilities: session.abilities,
|
||
ip: sessionIp,
|
||
user_agent: session.user_agent || "",
|
||
last_used_at: lastUsedAt,
|
||
created_at: createdAt,
|
||
expires_at: expiresAt,
|
||
is_current: Boolean(session.is_current || session.current || session.is_login || session.this_device)
|
||
};
|
||
});
|
||
|
||
var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
|
||
|
||
return {
|
||
online_ip_count: Object.keys(ipMap).length,
|
||
online_ips: Object.keys(ipMap),
|
||
online_device_count: Object.keys(deviceMap).length,
|
||
device_limit: extra.device_limit || (sub ? (sub.device_limit || (sub.plan && sub.plan.device_limit) || null) : null),
|
||
last_online_at: toTimestamp(extra.last_online_at) || lastOnlineAt,
|
||
active_session_count: normalizedSessions.length,
|
||
sessions: normalizedSessions
|
||
};
|
||
}
|
||
|
||
function dashboardStat(value, label) {
|
||
return '<div class="dashboard-stat"><span class="value">' + escapeHtml(value) + '</span><span class="label">' + escapeHtml(label) + "</span></div>";
|
||
}
|
||
|
||
function kpiBox(label, value) {
|
||
return '<div class="kpi-box"><span class="kpi-label">' + label + '</span><span class="kpi-value">' + value + "</span></div>";
|
||
}
|
||
|
||
function sidebarLink(route, title, copy) {
|
||
return '<button class="sidebar-link' + (state.currentRoute === route ? ' is-active' : '') + '" data-action="navigate" data-route="' + escapeHtml(route) + '"><strong>' + escapeHtml(title) + '</strong><span>' + escapeHtml(copy) + "</span></button>";
|
||
}
|
||
|
||
function renderBrandMark() {
|
||
var logoUrl = getThemeLogo();
|
||
if (logoUrl) {
|
||
return '<span class="brand-mark brand-mark--image"><img src="' + escapeHtml(logoUrl) + '" alt="' + escapeHtml(theme.title || "Nebula") + ' logo" /></span>';
|
||
}
|
||
return '<span class="brand-mark" aria-hidden="true"></span>';
|
||
}
|
||
|
||
function renderThemeToggle() {
|
||
var isLight = state.themeMode === "light";
|
||
return [
|
||
'<button class="theme-switch' + (isLight ? ' is-light' : ' is-dark') + '" data-action="toggle-theme-mode" type="button" aria-label="Toggle Theme">',
|
||
'<span class="theme-switch__track">',
|
||
'<span class="theme-switch__icon theme-switch__icon--moon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></span>',
|
||
'<span class="theme-switch__icon theme-switch__icon--sun"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></span>',
|
||
'<span class="theme-switch__thumb"></span>',
|
||
'</span>',
|
||
'</button>'
|
||
].join("");
|
||
}
|
||
|
||
function renderRecordFooter() {
|
||
var records = [];
|
||
if (theme.config && theme.config.icpNo) {
|
||
records.push('<span>' + escapeHtml(theme.config.icpNo) + '</span>');
|
||
}
|
||
if (theme.config && theme.config.psbNo) {
|
||
records.push('<span>' + escapeHtml(theme.config.psbNo) + '</span>');
|
||
}
|
||
if (!records.length) {
|
||
return "";
|
||
}
|
||
return '<footer class="record-footer">' + records.join('<span class="record-divider">|</span>') + '</footer>';
|
||
}
|
||
|
||
function renderAuthPanelHeading() {
|
||
return [
|
||
'<div class="auth-panel-heading">',
|
||
'<p class="auth-panel-eyebrow">Welcome To</p>',
|
||
'<h2>' + escapeHtml(getAuthPanelTitle()) + "</h2>",
|
||
'</div>'
|
||
].join("");
|
||
}
|
||
|
||
function getAuthPanelTitle() {
|
||
if (state.mode === "register") {
|
||
return (theme.config && theme.config.registerTitle) || "Create your access.";
|
||
}
|
||
return (theme.config && theme.config.welcomeTarget) || (theme && theme.title) || "Your Space";
|
||
}
|
||
|
||
function onRouteChange() {
|
||
state.currentRoute = getCurrentRoute();
|
||
if (state.authToken && state.user) {
|
||
render();
|
||
}
|
||
}
|
||
|
||
function setCurrentRoute(route) {
|
||
var nextRoute = isValidRoute(route) ? route : "overview";
|
||
state.currentRoute = nextRoute;
|
||
window.location.hash = "#/" + nextRoute;
|
||
render();
|
||
}
|
||
|
||
function setNodePage(page) {
|
||
state.nodePage = page > 0 ? page : 1;
|
||
render();
|
||
}
|
||
|
||
function toggleThemeMode() {
|
||
state.themePreference = state.themeMode === "light" ? "dark" : "light";
|
||
persistThemePreference(state.themePreference);
|
||
applyThemeMode();
|
||
render();
|
||
}
|
||
|
||
function getCurrentRoute() {
|
||
var hash = String(window.location.hash || "");
|
||
var route = hash.indexOf("#/") === 0 ? hash.slice(2) : "";
|
||
return isValidRoute(route) ? route : "overview";
|
||
}
|
||
|
||
function getStoredThemePreference() {
|
||
var stored = "";
|
||
try {
|
||
stored = window.localStorage.getItem("nebula_theme_preference") || "";
|
||
} catch (error) {
|
||
stored = "";
|
||
}
|
||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||
return stored;
|
||
}
|
||
return getDefaultThemePreference();
|
||
}
|
||
|
||
function persistThemePreference(mode) {
|
||
try {
|
||
window.localStorage.setItem("nebula_theme_preference", mode);
|
||
} catch (error) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
function applyThemeMode() {
|
||
state.themeMode = resolveThemeMode(state.themePreference);
|
||
document.documentElement.dataset.themeMode = state.themeMode;
|
||
document.documentElement.dataset.themePreference = state.themePreference;
|
||
updateThemeColorMeta();
|
||
}
|
||
|
||
function getThemeToggleLabel() {
|
||
return state.themeMode === "light" ? "锟斤拷鎻拷鍠诧拷鎲█h妺鎾橈拷" : "锟斤拷鎻拷鍠熻捑鎲媷鑺嬫挊锟?;
|
||
}
|
||
|
||
function getRealNameVerificationState() {
|
||
return state.realNameVerification || {
|
||
enabled: false,
|
||
status: "unavailable",
|
||
status_label: "Unavailable",
|
||
can_submit: false,
|
||
real_name: "",
|
||
identity_no_masked: "",
|
||
reject_reason: "",
|
||
notice: "",
|
||
submitted_at: null,
|
||
reviewed_at: null
|
||
};
|
||
}
|
||
|
||
function getRealNameStatusTone(status) {
|
||
if (status === "approved") {
|
||
return "online";
|
||
}
|
||
if (status === "rejected") {
|
||
return "offline";
|
||
}
|
||
if (status === "pending") {
|
||
return "pending";
|
||
}
|
||
return "neutral";
|
||
}
|
||
|
||
function renderRealNameStatusChip(status, label) {
|
||
return '<span class="status-chip ' + getRealNameStatusTone(status) + '">' + escapeHtml(label || "锟借姲浒伙拷鍡嗭拷锟?) + '</span>';
|
||
}
|
||
|
||
function renderRealNameVerificationOverviewCard(extraClass) {
|
||
var verification = getRealNameVerificationState();
|
||
if (!verification.enabled) {
|
||
return "";
|
||
}
|
||
|
||
var submitted = verification.submitted_at ? formatDate(verification.submitted_at) : "-";
|
||
var reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : "-";
|
||
|
||
var html = [
|
||
'<article class="section-card glass-card ' + escapeHtml(extraClass || "span-12") + '">',
|
||
'<div class="section-head"><div><span class="tiny-pill">鎽梆Ъ拷闇堟柟锟?/span><h3>闇堟柟锟介澖鈯ワ拷</h3></div></div>',
|
||
'<div class="kpi-row">',
|
||
kpiBox("闇堟柟锟斤拷鍡嗭拷锟?, escapeHtml(verification.status_label || "锟借娋鎭曢渹锟?)),
|
||
kpiBox("锟界瑪锟芥啰鏋忥拷", escapeHtml(verification.real_name || "-")),
|
||
kpiBox("闇傦拷杈o拷鐟烇拷", 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>铦栨牚鎭曪拷鍟o拷锟斤拷</label><input name="confirm_password" type="password" placeholder="闇傜憰锟界攬鈭燂拷锟戒氦榘垫挅锟斤拷" minlength="8" required /></div>',
|
||
'<div class="form-actions" style="margin-top:12px;">',
|
||
'<button class="btn btn-primary" type="submit">铦¤鏈栭澖鏍笺嚎</button>',
|
||
'</div>',
|
||
'</form>',
|
||
"</article>",
|
||
'<article class="section-card glass-card span-12" style="margin-top:24px;">',
|
||
'<div class="section-head"><div><span class="tiny-pill">鎽板導锟斤拷婊拷</span><h3>闇堬絹锟介殲鏂わ拷铦炩垹锟?/h3></div></div>',
|
||
'<div class="kpi-row" style="margin-top:20px;">',
|
||
'<div class="kpi-box"><span class="kpi-label">IPv4 闇堬絹锟?/span><div class="toolbar" style="margin-top:12px;"><button class="btn btn-secondary" data-action="reset-security">锟芥花钄殲鏂わ拷</button></div></div>',
|
||
'<div class="kpi-box"><span class="kpi-label">IPv6 闇堬絹锟?/span><div class="toolbar" style="margin-top:12px;"><button class="btn btn-secondary" data-action="reset-ipv6-security">锟芥花钄殲鏂わ拷</button></div></div>',
|
||
'</div>',
|
||
'<div class="footer-note" style="margin-top:16px;">锟介灍鍏у殫稹伝锟借澋妗愯獦锟斤拷锟斤拷锟金︼拷锟芥仴锟斤拷鏇庯拷浜欙拷铦¤鏈栨啳鏈涳拷鍤楀硶锟斤拷锟介柆锟斤拷锟藉暎閵侀澧ю栨拰浜欙拷锟解垹锟介姖鍓栵拷锟藉梿榘碉拷鏆桂栵拷锟?/div>',
|
||
'</article>'
|
||
].join("");
|
||
}
|
||
|
||
function getThemeLogo() {
|
||
var config = theme.config || {};
|
||
if (state.themeMode === "light") {
|
||
return config.lightLogoUrl || config.darkLogoUrl || theme.logo || "";
|
||
}
|
||
return config.darkLogoUrl || theme.logo || config.lightLogoUrl || "";
|
||
}
|
||
|
||
function updateThemeColorMeta() {
|
||
var meta = document.querySelector('meta[name="theme-color"]');
|
||
if (meta) {
|
||
meta.setAttribute("content", state.themeMode === "light" ? "#eef4fb" : "#08101c");
|
||
}
|
||
}
|
||
|
||
function getDefaultThemePreference() {
|
||
var configMode = theme && theme.config ? theme.config.defaultThemeMode : "";
|
||
if (configMode === "light" || configMode === "dark" || configMode === "system") {
|
||
return configMode;
|
||
}
|
||
return "system";
|
||
}
|
||
|
||
function resolveThemeMode(preference) {
|
||
if (preference === "light" || preference === "dark") {
|
||
return preference;
|
||
}
|
||
return isSystemDarkMode() ? "dark" : "light";
|
||
}
|
||
|
||
function bindSystemThemeListener() {
|
||
if (!themeMediaQuery) {
|
||
return;
|
||
}
|
||
var onThemeChange = function () {
|
||
if (state.themePreference === "system") {
|
||
applyThemeMode();
|
||
render();
|
||
}
|
||
};
|
||
if (typeof themeMediaQuery.addEventListener === "function") {
|
||
themeMediaQuery.addEventListener("change", onThemeChange);
|
||
return;
|
||
}
|
||
if (typeof themeMediaQuery.addListener === "function") {
|
||
themeMediaQuery.addListener(onThemeChange);
|
||
}
|
||
}
|
||
|
||
function getThemeMediaQuery() {
|
||
if (!window.matchMedia) {
|
||
return null;
|
||
}
|
||
return window.matchMedia("(prefers-color-scheme: dark)");
|
||
}
|
||
|
||
function isSystemDarkMode() {
|
||
return Boolean(themeMediaQuery && themeMediaQuery.matches);
|
||
}
|
||
|
||
function getMetricsBaseUrl() {
|
||
var baseUrl = theme && theme.config ? theme.config.metricsBaseUrl : "";
|
||
return String(baseUrl || "").replace(/\/+$/, "");
|
||
}
|
||
|
||
function getUplinkHeadline() {
|
||
if (!Number.isFinite(state.uplinkMbps)) {
|
||
return "Delivering -- Mbps";
|
||
}
|
||
return "Delivering " + formatUplinkMbps(state.uplinkMbps) + " Mbps";
|
||
}
|
||
|
||
function renderUplinkMetricStack() {
|
||
return [
|
||
'<div class="hero-metric-line">Delivering</div>',
|
||
'<div class="hero-metric-value"><em>' + escapeHtml(getUplinkValueText()) + '</em></div>',
|
||
'<div class="hero-metric-line">Uplink Bandwidth</div>'
|
||
].join("");
|
||
}
|
||
|
||
function getUplinkValueText() {
|
||
if (!Number.isFinite(state.uplinkMbps)) {
|
||
return "-- Mbps";
|
||
}
|
||
return formatUplinkMbps(state.uplinkMbps) + " Mbps";
|
||
}
|
||
|
||
function formatUplinkMbps(value) {
|
||
if (value >= 100) {
|
||
return String(Math.round(value));
|
||
}
|
||
if (value >= 10) {
|
||
return value.toFixed(1);
|
||
}
|
||
return value.toFixed(2);
|
||
}
|
||
|
||
function isValidRoute(route) {
|
||
return [
|
||
"overview",
|
||
"access-history",
|
||
"nodes",
|
||
"ipv6-nodes",
|
||
"tickets",
|
||
"real-name",
|
||
"security"
|
||
].indexOf(route) !== -1;
|
||
}
|
||
|
||
function getRouteTitle(route) {
|
||
if (route === "access-history") {
|
||
return "闇堣几鋽湀鍟o拷";
|
||
}
|
||
if (route === "nodes") {
|
||
return "锟斤拷锟?;
|
||
}
|
||
if (route === "ipv6-nodes") {
|
||
return "IPv6 锟斤拷锟?;
|
||
}
|
||
if (route === "notices") {
|
||
return "锟戒亥锟芥懆锟?;
|
||
}
|
||
if (route === "tickets") {
|
||
return "鎾屼簷锟?;
|
||
}
|
||
if (route === "real-name") {
|
||
return "\u5b9e\u540d\u8ba4\u8bc1";
|
||
}
|
||
if (route === "security") {
|
||
return "闊愯鍣℃懓鍖э拷";
|
||
}
|
||
return "锟介锟?;
|
||
}
|
||
|
||
function tabButton(mode, label, active) {
|
||
return '<button class="form-tab ' + (active ? "active" : "") + '" data-action="switch-mode" data-mode="' + mode + '">' + label + "</button>";
|
||
}
|
||
|
||
function getStoredToken() {
|
||
var candidates = [];
|
||
collectStorageValues(window.localStorage, ["access_token", "auth_data", "__nebula_auth_data__", "token", "auth_token"], candidates);
|
||
collectStorageValues(window.sessionStorage, ["access_token", "auth_data", "__nebula_auth_data__", "token", "auth_token"], candidates);
|
||
for (var i = 0; i < candidates.length; i += 1) {
|
||
var token = extractToken(candidates[i]);
|
||
if (token) {
|
||
return token;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function saveToken(token) {
|
||
var normalized = normalizeAuthHeader(token);
|
||
window.localStorage.setItem("access_token", normalized);
|
||
window.localStorage.setItem("auth_data", normalized);
|
||
window.localStorage.setItem("__nebula_auth_data__", normalized);
|
||
window.sessionStorage.setItem("access_token", normalized);
|
||
window.sessionStorage.setItem("auth_data", normalized);
|
||
window.sessionStorage.setItem("__nebula_auth_data__", normalized);
|
||
}
|
||
|
||
function clearToken() {
|
||
window.localStorage.removeItem("access_token");
|
||
window.localStorage.removeItem("auth_data");
|
||
window.localStorage.removeItem("__nebula_auth_data__");
|
||
window.sessionStorage.removeItem("access_token");
|
||
window.sessionStorage.removeItem("auth_data");
|
||
window.sessionStorage.removeItem("__nebula_auth_data__");
|
||
state.authToken = "";
|
||
}
|
||
|
||
function getStoredIpv6Token() {
|
||
var stored = "";
|
||
try {
|
||
stored = window.localStorage.getItem("__nebula_ipv6_auth_data__") || "";
|
||
} catch (e) {
|
||
stored = "";
|
||
}
|
||
return stored;
|
||
}
|
||
|
||
function saveIpv6Token(token) {
|
||
window.localStorage.setItem("__nebula_ipv6_auth_data__", normalizeAuthHeader(token));
|
||
}
|
||
|
||
function clearIpv6Token() {
|
||
window.localStorage.removeItem("__nebula_ipv6_auth_data__");
|
||
state.ipv6AuthToken = "";
|
||
}
|
||
|
||
function resetIpv6DashboardState() {
|
||
clearIpv6Token();
|
||
state.ipv6User = null;
|
||
state.ipv6Subscribe = null;
|
||
state.ipv6Eligibility = null;
|
||
state.ipv6Servers = [];
|
||
state.ipv6SessionOverview = null;
|
||
state.ipv6CachedSubNodes = null;
|
||
window.sessionStorage.removeItem("__nebula_ipv6_sub_content__");
|
||
}
|
||
|
||
function handleIpv6AuthFailure(error) {
|
||
if (!error || (error.status !== 401 && error.status !== 403)) {
|
||
return false;
|
||
}
|
||
resetIpv6DashboardState();
|
||
return true;
|
||
}
|
||
|
||
function resetDashboard() {
|
||
state.user = null;
|
||
state.subscribe = null;
|
||
state.stats = null;
|
||
state.knowledge = [];
|
||
state.tickets = [];
|
||
state.selectedTicketId = null;
|
||
state.selectedTicket = null;
|
||
state.servers = [];
|
||
state.ipv6AuthToken = "";
|
||
state.ipv6User = null;
|
||
state.ipv6Subscribe = null;
|
||
state.ipv6Eligibility = null;
|
||
state.ipv6Servers = [];
|
||
state.sessionOverview = null;
|
||
state.ipv6SessionOverview = null;
|
||
state.realNameVerification = null;
|
||
state.appConfig = null;
|
||
state.cachedSubNodes = null;
|
||
state.ipv6CachedSubNodes = null;
|
||
state.loading = false;
|
||
}
|
||
|
||
function applyCustomBackground() {
|
||
if (!theme.config || !theme.config.backgroundUrl) {
|
||
document.documentElement.style.removeProperty("--nebula-bg-image");
|
||
return;
|
||
}
|
||
document.documentElement.style.setProperty("--nebula-bg-image", 'url("' + String(theme.config.backgroundUrl).replace(/"/g, '\\"') + '")');
|
||
}
|
||
|
||
function getVerifyToken() {
|
||
var url = new URL(window.location.href);
|
||
var directVerify = url.searchParams.get("verify");
|
||
if (directVerify) {
|
||
return directVerify;
|
||
}
|
||
if (window.location.hash.indexOf("?") !== -1) {
|
||
return new URLSearchParams(window.location.hash.split("?")[1] || "").get("verify");
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function clearVerifyToken() {
|
||
var url = new URL(window.location.href);
|
||
url.searchParams.delete("verify");
|
||
if (window.location.hash.indexOf("?") !== -1) {
|
||
window.history.replaceState({}, "", url.pathname + url.search + window.location.hash.split("?")[0]);
|
||
return;
|
||
}
|
||
window.history.replaceState({}, "", url.pathname + url.search);
|
||
}
|
||
|
||
function showMessage(message, type) {
|
||
var container = document.getElementById("nebula-toasts");
|
||
if (!container) return;
|
||
|
||
if (!message) {
|
||
container.innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = '<div class="nebula-toast-container"><div class="message-banner ' + escapeHtml(type || "") + '">' + escapeHtml(message) + "</div></div>";
|
||
|
||
// Auto clear after 4s
|
||
if (state.messageTimeout) clearTimeout(state.messageTimeout);
|
||
state.messageTimeout = window.setTimeout(function() {
|
||
container.innerHTML = "";
|
||
}, 4000);
|
||
}
|
||
|
||
function hideLoader() {
|
||
if (!loader) {
|
||
return;
|
||
}
|
||
loader.classList.add("is-hidden");
|
||
window.setTimeout(function () {
|
||
if (loader && loader.parentNode) {
|
||
loader.parentNode.removeChild(loader);
|
||
loader = null;
|
||
}
|
||
}, 350);
|
||
}
|
||
|
||
function collectStorageValues(storage, keys, target) {
|
||
if (!storage) {
|
||
return;
|
||
}
|
||
for (var i = 0; i < keys.length; i += 1) {
|
||
pushCandidate(storage.getItem(keys[i]), target);
|
||
}
|
||
}
|
||
|
||
function pushCandidate(value, target) {
|
||
if (value === null || typeof value === "undefined" || value === "") {
|
||
return;
|
||
}
|
||
target.push(value);
|
||
}
|
||
|
||
function extractToken(value, depth) {
|
||
depth = depth || 0;
|
||
if (depth > 4 || value === null || typeof value === "undefined") {
|
||
return "";
|
||
}
|
||
if (typeof value === "string") {
|
||
var trimmed = value.trim();
|
||
if (!trimmed) {
|
||
return "";
|
||
}
|
||
if (trimmed.indexOf("Bearer ") === 0) {
|
||
return trimmed;
|
||
}
|
||
if (/^[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+(\.[A-Za-z0-9\-_.+/=]+)?$/.test(trimmed)) {
|
||
return "Bearer " + trimmed;
|
||
}
|
||
if (/^[A-Za-z0-9\-_.+/=]{24,}$/.test(trimmed) && trimmed.indexOf("{") === -1) {
|
||
return "Bearer " + trimmed;
|
||
}
|
||
if ((trimmed.charAt(0) === "{" && trimmed.charAt(trimmed.length - 1) === "}") ||
|
||
(trimmed.charAt(0) === "[" && trimmed.charAt(trimmed.length - 1) === "]")) {
|
||
try {
|
||
return extractToken(JSON.parse(trimmed), depth + 1);
|
||
} catch (error) {
|
||
return "";
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
if (Array.isArray(value)) {
|
||
for (var i = 0; i < value.length; i += 1) {
|
||
var arrayToken = extractToken(value[i], depth + 1);
|
||
if (arrayToken) {
|
||
return arrayToken;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
if (typeof value === "object") {
|
||
var directKeys = ["access_token", "auth_data", "token", "Authorization", "authorization"];
|
||
for (var j = 0; j < directKeys.length; j += 1) {
|
||
if (Object.prototype.hasOwnProperty.call(value, directKeys[j])) {
|
||
var directToken = extractToken(value[directKeys[j]], depth + 1);
|
||
if (directToken) {
|
||
return directToken;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function normalizeAuthHeader(token) {
|
||
var trimmed = String(token || "").trim();
|
||
if (!trimmed) {
|
||
return "";
|
||
}
|
||
if (trimmed.indexOf("Bearer ") === 0) {
|
||
return trimmed;
|
||
}
|
||
return "Bearer " + trimmed;
|
||
}
|
||
|
||
function copyText(value, successMessage) {
|
||
if (!value) {
|
||
return;
|
||
}
|
||
navigator.clipboard.writeText(value).then(function () {
|
||
showMessage(successMessage || "锟斤拷鎹嗘拰鑴o拷锟藉梾锟斤拷鑺炬枑锟斤拷", "success");
|
||
render();
|
||
}).catch(function () {
|
||
showMessage("鎲粴锟芥啳姊彇鍤楄绐堟挔鑲斤拷锟借稷啞鎲粴锟?, "error");
|
||
render();
|
||
});
|
||
}
|
||
|
||
function formatTraffic(value) {
|
||
var units = ["B", "KB", "MB", "GB", "TB"];
|
||
var size = Number(value || 0);
|
||
var index = 0;
|
||
while (size >= 1024 && index < units.length - 1) {
|
||
size /= 1024;
|
||
index += 1;
|
||
}
|
||
return size.toFixed(size >= 10 || index === 0 ? 0 : 1) + " " + units[index];
|
||
}
|
||
|
||
function formatMoney(value, symbol) {
|
||
return (symbol || "$") + (Number(value || 0) / 100).toFixed(2);
|
||
}
|
||
|
||
function formatSpeed(value) {
|
||
if (!value) {
|
||
return "閵濇激锟?;
|
||
}
|
||
return value + " Mbps";
|
||
}
|
||
|
||
function formatLimit(value) {
|
||
if (value === null || typeof value === "undefined" || value === "") {
|
||
return "閵濇激锟?;
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
function formatDate(value) {
|
||
var timestamp = toTimestamp(value);
|
||
if (!timestamp) {
|
||
return "-";
|
||
}
|
||
return new Date(timestamp * 1000).toLocaleString();
|
||
}
|
||
|
||
function toTimestamp(value) {
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
if (typeof value === "number") {
|
||
return value;
|
||
}
|
||
var parsed = Date.parse(value);
|
||
return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000);
|
||
}
|
||
|
||
function truncate(text, maxLength) {
|
||
var str = String(text || "");
|
||
if (str.length <= maxLength) {
|
||
return str;
|
||
}
|
||
return str.slice(0, maxLength - 1) + "...";
|
||
}
|
||
|
||
function firstNonEmpty(values) {
|
||
for (var i = 0; i < values.length; i += 1) {
|
||
if (values[i]) {
|
||
return values[i];
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value || "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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();
|
||
}
|
||
};
|
||
}
|
||
})();
|
||
|